├── .commitlintrc.json ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── node-ci.yml ├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── demo ├── .gitignore ├── next.config.js ├── package-lock.json ├── package.json └── pages │ ├── _app.js │ ├── index.js │ └── index.module.css ├── dist └── index.css ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── ContentfulImage.js ├── constants │ └── index.js ├── index.js └── utils │ ├── build-url-parameters.js │ ├── index.js │ └── mappers.js └── tests └── ContentfulImage.test.js /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [package.json] 13 | indent_size = 2 14 | 15 | [{*.md,*.snap}] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "@moxy/eslint-config-base/esm", 9 | "@moxy/eslint-config-babel", 10 | "@moxy/eslint-config-react", 11 | "@moxy/eslint-config-jest" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/node-ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - demo/**/* 7 | 8 | jobs: 9 | 10 | check: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: ['12', '14'] 15 | name: "[v${{ matrix.node-version }}] check" 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v1 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | npm ci 29 | 30 | - name: Run lint & tests 31 | env: 32 | CI: 1 33 | run: | 34 | npm run lint 35 | npm t 36 | 37 | - name: Submit coverage 38 | uses: codecov/codecov-action@v1 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | fail_ci_if_error: true 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | coverage 4 | lib/ 5 | es/ 6 | dist/ 7 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": "eslint" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## 1.0.0 (2020-04-29) 6 | 7 | 8 | ### Features 9 | 10 | * first implementation ([#1](https://github.com/moxystudio/react-contentful-image/issues/1)) ([2154918](https://github.com/moxystudio/react-contentful-image/commit/2154918790bf825584422eb3a31c6d89e82ce5fa)) 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Made With MOXY Lda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-contentful-image 2 | 3 | [![NPM version][npm-image]][npm-url] [![Downloads][downloads-image]][npm-url] [![Build Status][build-status-image]][build-status-url] [![Coverage Status][codecov-image]][codecov-url] [![Dependency status][david-dm-image]][david-dm-url] [![Dev Dependency status][david-dm-dev-image]][david-dm-dev-url] 4 | 5 | [npm-url]:https://npmjs.org/package/@moxy/react-contentful-image 6 | [downloads-image]:https://img.shields.io/npm/dm/@moxy/react-contentful-image.svg 7 | [npm-image]:https://img.shields.io/npm/v/@moxy/react-contentful-image.svg 8 | [build-status-url]:https://github.com/moxystudio/react-contentful-image/actions 9 | [build-status-image]:https://img.shields.io/github/workflow/status/moxystudio/react-contentful-image/Node%20CI/master 10 | [codecov-url]:https://codecov.io/gh/moxystudio/react-contentful-image 11 | [codecov-image]:https://img.shields.io/codecov/c/github/moxystudio/react-contentful-image/master.svg 12 | [david-dm-url]:https://david-dm.org/moxystudio/react-contentful-image 13 | [david-dm-image]:https://img.shields.io/david/moxystudio/react-contentful-image.svg 14 | [david-dm-dev-url]:https://david-dm.org/moxystudio/react-contentful-image?type=dev 15 | [david-dm-dev-image]:https://img.shields.io/david/dev/moxystudio/react-contentful-image.svg 16 | 17 | A react image renderer that uses the Contentful Images API. 18 | 19 | ## Installation 20 | 21 | ```sh 22 | $ npm install @moxy/react-contentful-image 23 | ``` 24 | 25 | This library is written in modern JavaScript and is published in both CommonJS and ES module transpiled variants. If you target older browsers please make sure to transpile accordingly. 26 | 27 | ## Motivation 28 | 29 | [Contentful](https://www.contentful.com/) provides a very powerful [Images API](https://www.contentful.com/developers/docs/references/images-api/) that besides retrieving image files, provides manipulation features such as resizing, cropping and compressing. 30 | 31 | This react component returns a `` element. If no manipulations are made, the `` contains at least one `` element, otherwise it will have two ``. It also contains the native `` as fallback for browsers that do not support the `` element. 32 | 33 | ## Usage 34 | 35 | ```js 36 | import React from 'react'; 37 | import ContentfulImage from '@moxy/react-contentful-image'; 38 | 39 | const src = "//images.ctfassets.net/yadj1kx9rmg0/wtrHxeu3zEoEce2MokCSi/cf6f68efdcf625fdc060607df0f3baef/quwowooybuqbl6ntboz3.jpg"; 40 | 41 | const MyComponent = (props) => ( 42 |
43 | 47 |
48 | ); 49 | 50 | export default MyComponent; 51 | ``` 52 | 53 | ## API 54 | 55 | Besides the following supported props by the `` component, additional props will be spread to the `` element. 56 | 57 | #### image 58 | 59 | Type: `string` or `object` | Required: `true` 60 | 61 | The actual image. It can be provided as an URL (`string`) or as a Contentful asset (`object`). 62 | 63 | 64 | A Contentful asset usually has the following structure: 65 | 66 | ```js 67 | { 68 | // ... 69 | fields: { 70 | // ... 71 | file: { 72 | // ... 73 | url: 'my-image-url', 74 | // ... 75 | }, 76 | // ... 77 | }, 78 | // ... 79 | } 80 | ``` 81 | 82 | Thus, you can pass it to this component and the image will be properly handled. 83 | 84 | Example: 85 | 86 | ```js 87 | const src = 'my-image-url'; 88 | 89 | 90 | ``` 91 | 92 | or 93 | 94 | ```js 95 | const { image } = page.fields; 96 | 97 | 98 | ``` 99 | 100 | #### format 101 | 102 | Type: `string` | Required: `false` | Default: `webp` 103 | 104 | The new format to convert the image. The possibilities are: 105 | - `webp` 106 | - `jpg` 107 | - `png` 108 | - `progressive jpg` 109 | - `8bit png` 110 | 111 | Example: 112 | 113 | ```js 114 | 117 | ``` 118 | 119 | If no `format` prop is passed, this component will convert the image to [`webp`](https://developers.google.com/speed/webp) by default as it provides small images weight with high visual quality. The example above will override this default value and will convert the image to `progressive jpg`. If you want to keep your image format with no conversions, please see [`optimize` prop](#optimize). 120 | 121 | ℹ️ Read more about Contentful Images API format conversion [here](https://www.contentful.com/developers/docs/references/images-api/#/reference/changing-formats). 122 | 123 | #### resize 124 | 125 | Type: `object` | Required: `false` 126 | 127 | Resizing configuration object. This object has the following shape: 128 | - `width` - Desired width 129 | - `height` - Desired height 130 | - `behavior` - Specifies the resizing behavior. The possible values are: 131 | - `pad` 132 | - `fill` 133 | - `scale` 134 | - `crop` 135 | - `thumb` 136 | - `focusArea` - Specifies the focus area when resizing. The possible values are: 137 | - `top` 138 | - `right` 139 | - `bottom` 140 | - `left` 141 | - `center` 142 | - `top_right` 143 | - `top_left` 144 | - `bottom_right` 145 | - `bottom_left` 146 | - `face` 147 | - `faces` 148 | 149 | Example: 150 | 151 | ```js 152 | const resizeValues = { 153 | width: 100, 154 | height: 100, 155 | behavior: 'crop', 156 | focusArea: 'top_right' 157 | }; 158 | 159 | // ... 160 | 161 | 164 | ``` 165 | 166 | ⚠️ Please, note the following warnings: 167 | - The maximum value for both `width` and `height` properties is 4000 pixels. 168 | - `focusArea` property won't have effect on the default or `scale` behavior. 169 | 170 | ℹ️ Read more about resizing images with Contentful Images API [here](https://www.contentful.com/developers/docs/references/images-api/#/reference/resizing-&-cropping). 171 | 172 | #### cropRadius 173 | 174 | Type: `string` or `number` | Required: `false` 175 | 176 | Add rounded corners or create a circle/ellipsis. The possible values are: 177 | 178 | - `max` keyword - Creates a full circle/ellipsis 179 | - The size of the corner radius in pixels 180 | 181 | Example: 182 | 183 | ```js 184 | 187 | 188 | // or 189 | 190 | 193 | ``` 194 | 195 | ℹ️ Read more about cropping images with Contentful Images API [here](https://www.contentful.com/developers/docs/references/images-api/#/reference/resizing-&-cropping/crop-rounded-corners-&-circle-elipsis). 196 | 197 | #### quality 198 | 199 | Type: `number` | Required: `false` 200 | 201 | Sets the quality of the image. The value must be between **1** and **100**. 202 | 203 | Example: 204 | 205 | ```js 206 | 210 | ``` 211 | 212 | ⚠️ This value will be ignored for 8-bit PNGs. 213 | 214 | ℹ️ Read more about changing the image quality with Contentful Images API [here](https://www.contentful.com/developers/docs/references/images-api/#/reference/image-manipulation/quality). 215 | 216 | #### backgroundColor 217 | 218 | Type: `string` | Required: `false` 219 | 220 | Sets the background color when using `cropRadius` or the `pad` behavior. Color hex code is expected as the value. 221 | 222 | Example: 223 | 224 | ```js 225 | 229 | ``` 230 | 231 | ℹ️ Read more about changing the image background color with Contentful Images API [here](https://www.contentful.com/developers/docs/references/images-api/#/reference/image-manipulation/background-color). 232 | 233 | #### optimize 234 | 235 | Type: `bool` | Required: `false` | Default: `true` 236 | 237 | If no `format` is passed, this component will use `webp` format as default. To convert to any other format, just use [`format` prop](#format) to override the default value. If you want to keep your image with no format manipulations set this prop to `false`. 238 | 239 | Example: 240 | 241 | ```js 242 | 245 | ``` 246 | 247 | 248 | ## Tests 249 | 250 | ```sh 251 | $ npm test 252 | $ npm test -- --watch # during development 253 | ``` 254 | 255 | ## Demo 256 | 257 | A demo [Next.js](https://nextjs.org/) project is available in the [`/demo`](./demo) folder so you can try out this component. 258 | 259 | First, build the `react-contentful-image` project with: 260 | 261 | ```sh 262 | $ npm run build 263 | ``` 264 | 265 | To run the demo, do the following inside the demo's folder: 266 | 267 | ```sh 268 | $ npm i 269 | $ npm run dev 270 | ``` 271 | 272 | *Note: Everytime a change is made to the package a rebuild is required to reflect those changes on the demo.* 273 | 274 | ## License 275 | 276 | Released under the [MIT License](./LICENSE). 277 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (api) => { 4 | api.cache(true); 5 | 6 | return { 7 | ignore: process.env.BABEL_ENV ? ['**/*.test.js', '**/__snapshots__', '**/__mocks__', '**/__fixtures__'] : [], 8 | presets: [ 9 | ['@moxy/babel-preset/lib', { react: true }], 10 | ], 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next/ 3 | out/ 4 | -------------------------------------------------------------------------------- /demo/next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-commonjs */ 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | webpack: (config) => { 6 | config.resolve.symlinks = false; 7 | config.resolve.alias.react = path.join(__dirname, '../node_modules/react'); 8 | config.resolve.alias['react-dom'] = path.join(__dirname, '../node_modules/react-dom'); 9 | 10 | return config; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.0.0", 4 | "description": "demo", 5 | "main": "index.js", 6 | "author": "Pedro Santos ", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/moxystudio/react-contentful-image.git" 11 | }, 12 | "scripts": { 13 | "dev": "onchange -i -k \"../lib/**/*.js\" -- next", 14 | "build": "next build", 15 | "start": "next start", 16 | "export": "next export" 17 | }, 18 | "dependencies": { 19 | "@moxy/react-contentful-image": "file:..", 20 | "next": "^9.2.0", 21 | "onchange": "^6.1.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /demo/pages/_app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React from 'react'; 3 | 4 | const App = ({ Component, pageProps }) => ( 5 | 6 | ); 7 | 8 | export default App; 9 | -------------------------------------------------------------------------------- /demo/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ContentfulImage from '@moxy/react-contentful-image'; 3 | 4 | import styles from './index.module.css'; 5 | 6 | const Home = () => ( 7 |
8 |

Contentful Image

9 | 13 |
14 | ); 15 | 16 | export default Home; 17 | -------------------------------------------------------------------------------- /demo/pages/index.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background-color: #CDCDCD; 3 | } 4 | -------------------------------------------------------------------------------- /dist/index.css: -------------------------------------------------------------------------------- 1 | .next-lib-template_container { 2 | background-color: black; 3 | color: white 4 | } 5 | .next-lib-template_container::after { 6 | content: ''; 7 | margin-bottom: 50px; 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { compose, baseConfig, withRTL } = require('@moxy/jest-config'); 4 | 5 | module.exports = compose( 6 | baseConfig(), 7 | withRTL(), 8 | ); 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@moxy/react-contentful-image", 3 | "version": "1.0.0", 4 | "description": "A react image renderer that uses the Contentful Images API.", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": [ 8 | "lib", 9 | "es", 10 | "dist" 11 | ], 12 | "homepage": "https://github.com/moxystudio/react-contentful-image#readme", 13 | "author": "Pedro Santos ", 14 | "license": "MIT", 15 | "keywords": [ 16 | "react", 17 | "images", 18 | "react-component", 19 | "contentful", 20 | "contentful-image", 21 | "contentful-images-api", 22 | "images-manipulation" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/moxystudio/react-contentful-image.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/moxystudio/react-contentful-image/issues" 30 | }, 31 | "scripts": { 32 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src -d lib --delete-dir-on-start", 33 | "build:es": "cross-env BABEL_ENV=es babel src -d es --delete-dir-on-start", 34 | "build": "npm run build:commonjs && npm run build:es", 35 | "test": "jest", 36 | "lint": "eslint --ignore-path .gitignore .", 37 | "prerelease": "npm t && npm run lint && npm run build", 38 | "release": "standard-version", 39 | "postrelease": "git push --follow-tags origin HEAD && npm publish" 40 | }, 41 | "peerDependencies": { 42 | "react": "^16.12.0", 43 | "react-dom": "^16.12.0" 44 | }, 45 | "dependencies": { 46 | "lodash.get": "^4.4.2", 47 | "prop-types": "^15.7.2" 48 | }, 49 | "devDependencies": { 50 | "@babel/cli": "^7.2.3", 51 | "@babel/core": "^7.3.4", 52 | "@commitlint/config-conventional": "^8.0.0", 53 | "@moxy/babel-preset": "^3.2.1", 54 | "@moxy/eslint-config-babel": "^12.0.0", 55 | "@moxy/eslint-config-base": "^12.0.0", 56 | "@moxy/eslint-config-jest": "^12.0.0", 57 | "@moxy/eslint-config-react": "^12.0.0", 58 | "@moxy/jest-config": "^4.0.1", 59 | "@moxy/postcss-preset": "^4.4.2", 60 | "@testing-library/react": "^10.0.2", 61 | "commitlint": "^8.3.5", 62 | "cross-env": "^7.0.2", 63 | "eslint": "^6.0.0", 64 | "husky": "^4.0.10", 65 | "jest": "^25.2.6", 66 | "lint-staged": "^10.0.2", 67 | "react": "^16.12.0", 68 | "react-dom": "^16.12.0", 69 | "rimraf": "^3.0.2", 70 | "standard-version": "^7.0.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/ContentfulImage.js: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useMemo } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import get from 'lodash.get'; 4 | import buildUrlParameters from './utils'; 5 | import { FORMAT, RESIZE, CROP, QUALITY, BACKGROUND_COLOR } from './constants'; 6 | 7 | const ContentfulImage = forwardRef(({ format, optimize, resize, cropRadius, quality, backgroundColor, image, ...imageProps }, ref) => { 8 | const imageData = useMemo(() => { 9 | const originalUrl = typeof image === 'string' ? image : get(image, 'fields.file.url'); 10 | 11 | if (!originalUrl) { 12 | return; 13 | } 14 | 15 | const urlParameters = []; 16 | let returnedMimeType; 17 | 18 | if (optimize) { 19 | const { url, mimeType } = buildUrlParameters({ key: FORMAT, value: format }); 20 | 21 | urlParameters.push(...url); 22 | returnedMimeType = mimeType; 23 | } 24 | 25 | if (resize) { 26 | const url = buildUrlParameters({ key: RESIZE, value: resize }); 27 | 28 | urlParameters.push(...url); 29 | } 30 | 31 | if (cropRadius) { 32 | const url = buildUrlParameters({ key: CROP, value: cropRadius }); 33 | 34 | urlParameters.push(...url); 35 | } 36 | 37 | if (quality) { 38 | const url = buildUrlParameters({ key: QUALITY, value: quality }); 39 | 40 | urlParameters.push(...url); 41 | } 42 | 43 | if (backgroundColor) { 44 | const url = buildUrlParameters({ key: BACKGROUND_COLOR, value: backgroundColor }); 45 | 46 | urlParameters.push(...url); 47 | } 48 | 49 | const enhancedUrl = urlParameters.length ? 50 | `${originalUrl}?${urlParameters.join('&')}` : 51 | originalUrl; 52 | 53 | return { 54 | enhancedUrl, 55 | mimeType: returnedMimeType, 56 | originalUrl, 57 | }; 58 | }, [image, format, resize, optimize, cropRadius, quality, backgroundColor]); 59 | 60 | if (!imageData) { 61 | console.error('ContentfulImage: Could not retrieve an URL from the `image` prop. Please check your object structure.'); 62 | 63 | return null; 64 | } 65 | 66 | const { enhancedUrl, mimeType, originalUrl } = imageData; 67 | 68 | return ( 69 | 70 | { enhancedUrl !== originalUrl && } 71 | 72 | 73 | 74 | ); 75 | }); 76 | 77 | ContentfulImage.propTypes = { 78 | optimize: PropTypes.bool, 79 | quality: PropTypes.number, 80 | image: PropTypes.oneOfType([ 81 | PropTypes.string, 82 | PropTypes.object, 83 | ]).isRequired, 84 | backgroundColor: PropTypes.string, 85 | cropRadius: PropTypes.oneOfType([ 86 | PropTypes.oneOf(['max']), 87 | PropTypes.number, 88 | ]), 89 | format: PropTypes.oneOf([ 90 | 'webp', 91 | 'jpg', 92 | 'png', 93 | 'progressive jpg', 94 | '8bit png', 95 | ]), 96 | resize: PropTypes.shape({ 97 | width: PropTypes.number, 98 | height: PropTypes.number, 99 | behavior: PropTypes.oneOf([ 100 | 'pad', 101 | 'fill', 102 | 'scale', 103 | 'crop', 104 | 'thumb', 105 | ]), 106 | focusArea: PropTypes.oneOf([ 107 | 'top', 108 | 'right', 109 | 'bottom', 110 | 'left', 111 | 'center', 112 | 'top_right', 113 | 'top_left', 114 | 'bottom_right', 115 | 'bottom_left', 116 | 'face', 117 | 'faces', 118 | ]), 119 | }), 120 | }; 121 | 122 | ContentfulImage.defaultProps = { 123 | format: 'webp', 124 | optimize: true, 125 | }; 126 | 127 | export default ContentfulImage; 128 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | const FORMAT = 'format'; 2 | const RESIZE = 'resize'; 3 | const CROP = 'cropRadius'; 4 | const QUALITY = 'quality'; 5 | const BACKGROUND_COLOR = 'backgroundColor'; 6 | 7 | export { 8 | FORMAT, 9 | RESIZE, 10 | CROP, 11 | QUALITY, 12 | BACKGROUND_COLOR, 13 | }; 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ContentfulImage'; 2 | -------------------------------------------------------------------------------- /src/utils/build-url-parameters.js: -------------------------------------------------------------------------------- 1 | import { FORMAT, RESIZE, CROP, QUALITY, BACKGROUND_COLOR } from '../constants'; 2 | import { parametersMapper, valuesMapper } from './mappers'; 3 | 4 | const buildUrlParametersFormat = ({ key, value }) => { 5 | const conversion = value.split(' '); 6 | const formatParameter = parametersMapper[key].format; 7 | const compressionParameter = parametersMapper[key].compression; 8 | 9 | if (conversion.length === 2) { 10 | const compressionValue = valuesMapper[key].compression[conversion[0]]; 11 | const url = `${formatParameter}=${conversion[1]}&${compressionParameter}=${compressionValue}`; 12 | 13 | return { 14 | url: [url], 15 | mimeType: `image/${valuesMapper[key].mimeType[conversion[1]]}`, 16 | }; 17 | } 18 | 19 | const url = `${formatParameter}=${conversion[0]}`; 20 | 21 | return { 22 | url: [url], 23 | mimeType: `image/${valuesMapper[key].mimeType[conversion[0]]}`, 24 | }; 25 | }; 26 | 27 | const buildUrlParametersResize = ({ key, value }) => { 28 | const resizeParameters = Object.keys(value).map((element) => { 29 | const parameter = parametersMapper[key][element]; 30 | 31 | return `${parameter}=${value[element]}`; 32 | }); 33 | 34 | return resizeParameters; 35 | }; 36 | 37 | const buildSimpleUrlParameter = ({ key, value }) => { 38 | const parameter = parametersMapper[key]; 39 | 40 | return [`${parameter}=${value}`]; 41 | }; 42 | 43 | const buildUrlParameterBackgroundColor = ({ key, value }) => { 44 | const parameter = parametersMapper[key]; 45 | const finalValue = value.replace('#', 'rgb:'); 46 | 47 | return [`${parameter}=${finalValue}`]; 48 | }; 49 | 50 | const buildUrlParameters = (options) => { 51 | const { key } = options; 52 | 53 | switch (key) { 54 | case FORMAT: 55 | return buildUrlParametersFormat(options); 56 | case RESIZE: 57 | return buildUrlParametersResize(options); 58 | case CROP: 59 | case QUALITY: 60 | return buildSimpleUrlParameter(options); 61 | case BACKGROUND_COLOR: 62 | return buildUrlParameterBackgroundColor(options); 63 | default: 64 | break; 65 | } 66 | }; 67 | 68 | export default buildUrlParameters; 69 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './build-url-parameters'; 2 | -------------------------------------------------------------------------------- /src/utils/mappers.js: -------------------------------------------------------------------------------- 1 | import { FORMAT, RESIZE, CROP, QUALITY, BACKGROUND_COLOR } from '../constants'; 2 | 3 | const parametersMapper = { 4 | [FORMAT]: { 5 | format: 'fm', 6 | compression: 'fl', 7 | }, 8 | [RESIZE]: { 9 | width: 'w', 10 | height: 'h', 11 | behavior: 'fit', 12 | focusArea: 'f', 13 | }, 14 | [CROP]: 'r', 15 | [QUALITY]: 'q', 16 | [BACKGROUND_COLOR]: 'bg', 17 | }; 18 | 19 | const valuesMapper = { 20 | [FORMAT]: { 21 | compression: { 22 | '8bit': 'png8', 23 | progressive: 'progressive', 24 | }, 25 | mimeType: { 26 | webp: 'webp', 27 | jpg: 'jpeg', 28 | png: 'png', 29 | }, 30 | }, 31 | }; 32 | 33 | export { 34 | valuesMapper, 35 | parametersMapper, 36 | }; 37 | -------------------------------------------------------------------------------- /tests/ContentfulImage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import ContentfulImage from '../src/ContentfulImage'; 4 | 5 | const imageSrc = '//images.ctfassets.net/yadj1kx9rmg0/wtrHxeu3zEoEce2MokCSi/cf6f68efdcf625fdc060607df0f3baef/quwowooybuqbl6ntboz3.jpg'; 6 | const defaultProps = { image: imageSrc }; 7 | 8 | const renderWithProps = (props = {}) => render(); 9 | 10 | const renderAndRetrieveElements = (props = {}) => { 11 | const { getByTestId } = renderWithProps({ ...props }); 12 | const pictureElem = getByTestId('picture'); 13 | 14 | const children = Array.from(pictureElem.childNodes).reduce((acc, node, index) => { 15 | acc[`elem${index}`] = node; 16 | 17 | return acc; 18 | }, {}); 19 | 20 | return { 21 | pictureElem, 22 | ...children, 23 | }; 24 | }; 25 | 26 | beforeEach(() => { 27 | console.error.mock && console.error.mockRestore(); 28 | }); 29 | 30 | describe('ContentfulImage component', () => { 31 | it('should render correctly', () => { 32 | const { 33 | pictureElem, 34 | elem0: firstSourceElem, 35 | elem1: fallbackSourceElem, 36 | elem2: fallbackImgElem, 37 | } = renderAndRetrieveElements(); 38 | 39 | expect(pictureElem.childNodes).toHaveLength(3); 40 | expect(firstSourceElem.tagName).toMatch(/source/i); 41 | expect(fallbackSourceElem.tagName).toMatch(/source/i); 42 | expect(fallbackImgElem.tagName).toMatch(/img/i); 43 | }); 44 | 45 | it('should accept an object for "image" prop', () => { 46 | const image = { fields: { file: { url: imageSrc } } }; 47 | 48 | const { 49 | pictureElem, 50 | elem0: firstSourceElem, 51 | elem1: fallbackSourceElem, 52 | elem2: fallbackImgElem, 53 | } = renderAndRetrieveElements({ image }); 54 | 55 | expect(pictureElem.childNodes).toHaveLength(3); 56 | expect(firstSourceElem.tagName).toMatch(/source/i); 57 | expect(fallbackSourceElem.tagName).toMatch(/source/i); 58 | expect(fallbackImgElem.tagName).toMatch(/img/i); 59 | }); 60 | 61 | it('should not render and should log an error when "image" prop is an object with incorrect structure', () => { 62 | jest.spyOn(console, 'error').mockImplementation(); 63 | 64 | const image = { fields: { file: { foo: imageSrc } } }; 65 | 66 | const { queryByTestId } = renderWithProps({ image }); 67 | const pictureElem = queryByTestId('picture'); 68 | 69 | expect(console.error.mock.calls[0][0]).toMatch('ContentfulImage: Could not retrieve an URL from the `image` prop. Please check your object structure.'); // eslint-disable-line max-len 70 | expect(pictureElem).not.toBeInTheDocument(); 71 | }); 72 | 73 | it('should convert to webp format by default', () => { 74 | const { 75 | elem0: firstSourceElem, 76 | elem1: fallbackSourceElem, 77 | elem2: fallbackImgElem, 78 | } = renderAndRetrieveElements(); 79 | 80 | expect(firstSourceElem).toHaveAttribute('srcset', `${imageSrc}?fm=webp`); 81 | expect(firstSourceElem).toHaveAttribute('type', 'image/webp'); 82 | expect(fallbackSourceElem).toHaveAttribute('srcset', imageSrc); 83 | expect(fallbackImgElem).toHaveAttribute('src', imageSrc); 84 | }); 85 | 86 | it('should not convert to any format when optimize prop is false', () => { 87 | const { 88 | pictureElem, 89 | elem0: sourceElem, 90 | elem1: fallbackImgElem, 91 | } = renderAndRetrieveElements({ optimize: false }); 92 | 93 | expect(pictureElem.childNodes).toHaveLength(2); 94 | expect(sourceElem).toHaveAttribute('srcset', imageSrc); 95 | expect(fallbackImgElem).toHaveAttribute('src', imageSrc); 96 | }); 97 | 98 | it('should convert to the required format', () => { 99 | const { 100 | elem0: firstSourceElem, 101 | elem1: fallbackSourceElem, 102 | elem2: fallbackImgElem, 103 | } = renderAndRetrieveElements({ format: 'progressive jpg' }); 104 | 105 | expect(firstSourceElem).toHaveAttribute('srcset', `${imageSrc}?fm=jpg&fl=progressive`); 106 | expect(firstSourceElem).toHaveAttribute('type', 'image/jpeg'); 107 | expect(fallbackSourceElem).toHaveAttribute('srcset', imageSrc); 108 | expect(fallbackImgElem).toHaveAttribute('src', imageSrc); 109 | }); 110 | 111 | it('should resize the image', () => { 112 | const { 113 | elem0: firstSourceElem, 114 | elem1: fallbackSourceElem, 115 | elem2: fallbackImgElem, 116 | } = renderAndRetrieveElements({ 117 | resize: { width: 40, height: 60, behavior: 'fill', focusArea: 'top' }, 118 | optimize: false, 119 | }); 120 | 121 | expect(firstSourceElem).toHaveAttribute('srcset', `${imageSrc}?w=40&h=60&fit=fill&f=top`); 122 | expect(fallbackSourceElem).toHaveAttribute('srcset', imageSrc); 123 | expect(fallbackImgElem).toHaveAttribute('src', imageSrc); 124 | }); 125 | 126 | it('should crop the image', () => { 127 | const { 128 | elem0: firstSourceElem, 129 | elem1: fallbackSourceElem, 130 | elem2: fallbackImgElem, 131 | } = renderAndRetrieveElements({ cropRadius: 20, optimize: false }); 132 | 133 | expect(firstSourceElem).toHaveAttribute('srcset', `${imageSrc}?r=20`); 134 | expect(fallbackSourceElem).toHaveAttribute('srcset', imageSrc); 135 | expect(fallbackImgElem).toHaveAttribute('src', imageSrc); 136 | }); 137 | 138 | it('should convert image to a different level of quality', () => { 139 | const { 140 | elem0: firstSourceElem, 141 | elem1: fallbackSourceElem, 142 | elem2: fallbackImgElem, 143 | } = renderAndRetrieveElements({ quality: 10, format: 'jpg' }); 144 | 145 | expect(firstSourceElem).toHaveAttribute('srcset', `${imageSrc}?fm=jpg&q=10`); 146 | expect(fallbackSourceElem).toHaveAttribute('srcset', imageSrc); 147 | expect(fallbackImgElem).toHaveAttribute('src', imageSrc); 148 | }); 149 | 150 | it('should add background-color to the image', () => { 151 | const { 152 | elem0: firstSourceElem, 153 | elem1: fallbackSourceElem, 154 | elem2: fallbackImgElem, 155 | } = renderAndRetrieveElements({ 156 | resize: { width: 100, height: 100, behavior: 'pad' }, 157 | backgroundColor: '#9090ff', 158 | }); 159 | 160 | expect(firstSourceElem).toHaveAttribute('srcset', `${imageSrc}?fm=webp&w=100&h=100&fit=pad&bg=rgb:9090ff`); 161 | expect(fallbackSourceElem).toHaveAttribute('srcset', imageSrc); 162 | expect(fallbackImgElem).toHaveAttribute('src', imageSrc); 163 | }); 164 | }); 165 | --------------------------------------------------------------------------------