From 1fc2f50499e0311eac033dcfce321e1106c4713b Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Tue, 4 Dec 2018 17:04:52 -0500 Subject: [PATCH] feat: add cloudinary support (#1932) --- .../src/actions/mediaLibrary.js | 4 +- .../netlify-cms-core/src/reducers/index.js | 11 ++- .../src/index.js | 3 + .../README.md | 11 +++ .../package.json | 35 ++++++++ .../src/index.js | 82 ++++++++++++++++++ .../webpack.config.js | 3 + .../src/index.js | 8 +- packages/netlify-cms-widget-file/package.json | 1 + .../src/withFileControl.js | 31 ++++++- packages/netlify-cms/src/media-libraries.js | 2 + website/content/docs/cloudinary.md | 84 +++++++++++++++++++ website/content/docs/uploadcare.md | 9 +- website/content/docs/widgets/file.md | 10 ++- website/content/docs/widgets/image.md | 4 +- 15 files changed, 281 insertions(+), 17 deletions(-) create mode 100644 packages/netlify-cms-media-library-cloudinary/README.md create mode 100644 packages/netlify-cms-media-library-cloudinary/package.json create mode 100644 packages/netlify-cms-media-library-cloudinary/src/index.js create mode 100644 packages/netlify-cms-media-library-cloudinary/webpack.config.js create mode 100644 website/content/docs/cloudinary.md diff --git a/packages/netlify-cms-core/src/actions/mediaLibrary.js b/packages/netlify-cms-core/src/actions/mediaLibrary.js index 2f6ef7a2..3b02744f 100644 --- a/packages/netlify-cms-core/src/actions/mediaLibrary.js +++ b/packages/netlify-cms-core/src/actions/mediaLibrary.js @@ -63,8 +63,8 @@ export function openMediaLibrary(payload = {}) { const state = getState(); const mediaLibrary = state.mediaLibrary.get('externalLibrary'); if (mediaLibrary) { - const { controlID: id, value, config = Map(), forImage } = payload; - mediaLibrary.show({ id, value, config: config.toJS(), imagesOnly: forImage }); + const { controlID: id, value, config = Map(), allowMultiple, forImage } = payload; + mediaLibrary.show({ id, value, config: config.toJS(), allowMultiple, imagesOnly: forImage }); } dispatch({ type: MEDIA_LIBRARY_OPEN, payload }); }; diff --git a/packages/netlify-cms-core/src/reducers/index.js b/packages/netlify-cms-core/src/reducers/index.js index c7bd2725..a5a44bb0 100644 --- a/packages/netlify-cms-core/src/reducers/index.js +++ b/packages/netlify-cms-core/src/reducers/index.js @@ -56,5 +56,12 @@ export const selectUnpublishedEntriesByStatus = (state, status) => export const selectIntegration = (state, collection, hook) => fromIntegrations.selectIntegration(state.integrations, collection, hook); -export const getAsset = (state, path) => - fromMedias.getAsset(state.config.get('public_folder'), state.medias, path); +export const getAsset = (state, path) => { + /** + * If an external media library is in use, just return the path. + */ + if (state.mediaLibrary.get('externalLibrary')) { + return path; + } + return fromMedias.getAsset(state.config.get('public_folder'), state.medias, path); +}; diff --git a/packages/netlify-cms-editor-component-image/src/index.js b/packages/netlify-cms-editor-component-image/src/index.js index a24ff072..9cd0544b 100644 --- a/packages/netlify-cms-editor-component-image/src/index.js +++ b/packages/netlify-cms-editor-component-image/src/index.js @@ -17,6 +17,9 @@ const image = { label: 'Image', name: 'image', widget: 'image', + media_library: { + allow_multiple: false, + }, }, { label: 'Alt Text', diff --git a/packages/netlify-cms-media-library-cloudinary/README.md b/packages/netlify-cms-media-library-cloudinary/README.md new file mode 100644 index 00000000..49f13fd3 --- /dev/null +++ b/packages/netlify-cms-media-library-cloudinary/README.md @@ -0,0 +1,11 @@ +# Docs coming soon! + +Netlify CMS was recently converted from a single npm package to a "monorepo" of over 20 packages. +That's over 20 Readme's! We haven't created one for this package yet, but we will soon. + +In the meantime, you can: + +1. Check out the [main readme](https://github.com/netlify/netlify-cms/#readme) or the [documentation + site](https://www.netlifycms.org) for more info. +2. Reach out to the [community chat](https://gitter.im/netlify/netlifycms/) if you need help. +3. Help out and [write the readme yourself](https://github.com/netlify/netlify-cms/edit/master/packages/netlify-cms-media-library-cloudinary/README.md)! diff --git a/packages/netlify-cms-media-library-cloudinary/package.json b/packages/netlify-cms-media-library-cloudinary/package.json new file mode 100644 index 00000000..2795a719 --- /dev/null +++ b/packages/netlify-cms-media-library-cloudinary/package.json @@ -0,0 +1,35 @@ +{ + "name": "netlify-cms-media-library-cloudinary", + "description": "Cloudinary integration for Netlify CMS", + "version": "1.0.0", + "repository": "https://github.com/netlify/netlify-cms/tree/master/packages/netlify-cms-media-library-cloudinary", + "bugs": "https://github.com/netlify/netlify-cms/issues", + "main": "dist/netlify-cms-media-library-cloudinary.js", + "license": "MIT", + "keywords": [ + "netlify", + "netlify-cms", + "cloudinary", + "image", + "images", + "media", + "assets", + "files", + "uploads" + ], + "sideEffects": false, + "scripts": { + "watch": "webpack -w", + "develop": "npm run watch", + "build": "cross-env NODE_ENV=production webpack" + }, + "devDependencies": { + "cross-env": "^5.2.0", + "webpack": "^4.16.1", + "webpack-cli": "^3.1.0" + }, + "peerDependencies": { + "netlify-cms-lib-util": "^2.0.4" + }, + "dependencies": {} +} diff --git a/packages/netlify-cms-media-library-cloudinary/src/index.js b/packages/netlify-cms-media-library-cloudinary/src/index.js new file mode 100644 index 00000000..be933490 --- /dev/null +++ b/packages/netlify-cms-media-library-cloudinary/src/index.js @@ -0,0 +1,82 @@ +import { pick } from 'lodash'; +import { loadScript } from 'netlify-cms-lib-util'; + +const defaultOptions = { + use_secure_url: true, + use_transformations: true, + output_filename_only: false, +}; +/** + * This configuration hash cannot be overriden, as the values here are required + * for the integration to work properly. + */ +const enforcedConfig = { + button_class: undefined, + inline_container: undefined, + insert_transformation: false, + z_index: '99999', +}; + +const defaultConfig = { + multiple: false, +}; + +function getAssetUrl(asset, { use_secure_url, use_transformations, output_filename_only }) { + /** + * Allow output of the file name only, in which case the rest of the url (including) + * transformations) can be handled by the static site generator. + */ + if (output_filename_only) { + return `${asset.public_id}.${asset.format}`; + } + + /** + * Get url from `derived` property if it exists. This property contains the + * transformed version of image if transformations have been applied. + */ + const urlObject = asset.derived && use_transformations ? asset.derived[0] : asset; + + /** + * Retrieve the `https` variant of the image url if the `useSecureUrl` option + * is set to `true` (this is the default setting). + */ + const urlKey = use_secure_url ? 'secure_url' : 'url'; + + return urlObject[urlKey]; +} + +async function init({ options, handleInsert }) { + const { config: providedConfig = {}, ...integrationOptions } = options; + const resolvedOptions = { ...defaultOptions, ...integrationOptions }; + const cloudinaryConfig = { ...defaultConfig, ...providedConfig, ...enforcedConfig }; + const cloudinaryBehaviorConfigKeys = ['default_transformations', 'max_files', 'multiple']; + const cloudinaryBehaviorConfig = pick(cloudinaryConfig, cloudinaryBehaviorConfigKeys); + + await loadScript('https://media-library.cloudinary.com/global/all.js'); + + const insertHandler = data => { + const assets = data.assets.map(asset => getAssetUrl(asset, resolvedOptions)); + handleInsert(cloudinaryConfig.multiple ? assets : assets[0]); + }; + + const mediaLibrary = window.cloudinary.createMediaLibrary(cloudinaryConfig, { insertHandler }); + + return { + show: ({ config: instanceConfig = {}, allowMultiple }) => { + /** + * Ensure multiple selection is not available if the field is configured + * to disallow it. + */ + if (allowMultiple === false) { + instanceConfig.multiple = false; + } + return mediaLibrary.show({ config: { ...cloudinaryBehaviorConfig, instanceConfig } }); + }, + hide: () => mediaLibrary.hide(), + enableStandalone: () => true, + }; +} + +const cloudinaryMediaLibrary = { name: 'cloudinary', init }; + +export default cloudinaryMediaLibrary; diff --git a/packages/netlify-cms-media-library-cloudinary/webpack.config.js b/packages/netlify-cms-media-library-cloudinary/webpack.config.js new file mode 100644 index 00000000..42edd361 --- /dev/null +++ b/packages/netlify-cms-media-library-cloudinary/webpack.config.js @@ -0,0 +1,3 @@ +const { getConfig } = require('../../scripts/webpack.js'); + +module.exports = getConfig(); diff --git a/packages/netlify-cms-media-library-uploadcare/src/index.js b/packages/netlify-cms-media-library-uploadcare/src/index.js index cecde0db..ca5890f4 100644 --- a/packages/netlify-cms-media-library-uploadcare/src/index.js +++ b/packages/netlify-cms-media-library-uploadcare/src/index.js @@ -113,8 +113,10 @@ async function init({ options = { config: {} }, handleInsert }) { * On show, create a new widget, cache it in the widgets object, and open. * No hide method is provided because the widget doesn't provide it. */ - show: ({ value, config: instanceConfig = {}, imagesOnly }) => { + show: ({ value, config: instanceConfig = {}, allowMultiple, imagesOnly }) => { const config = { ...baseConfig, imagesOnly, ...instanceConfig }; + const multiple = allowMultiple === false ? false : !!config.multiple; + const resolvedConfig = { ...config, multiple }; const files = getFiles(value); /** @@ -122,9 +124,9 @@ async function init({ options = { config: {} }, handleInsert }) { * from the Uploadcare library will have a `state` method. */ if (files && !files.state) { - files.then(result => openDialog(result, config, handleInsert)); + files.then(result => openDialog(result, resolvedConfig, handleInsert)); } else { - openDialog(files, config, handleInsert); + openDialog(files, resolvedConfig, handleInsert); } }, diff --git a/packages/netlify-cms-widget-file/package.json b/packages/netlify-cms-widget-file/package.json index 94f8c983..543dbf49 100644 --- a/packages/netlify-cms-widget-file/package.json +++ b/packages/netlify-cms-widget-file/package.json @@ -22,6 +22,7 @@ "build": "cross-env NODE_ENV=production webpack" }, "dependencies": { + "common-tags": "^1.8.0", "uuid": "^3.3.2" }, "devDependencies": { diff --git a/packages/netlify-cms-widget-file/src/withFileControl.js b/packages/netlify-cms-widget-file/src/withFileControl.js index 4830ee87..5777d428 100644 --- a/packages/netlify-cms-widget-file/src/withFileControl.js +++ b/packages/netlify-cms-widget-file/src/withFileControl.js @@ -2,8 +2,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import styled from 'react-emotion'; -import { List } from 'immutable'; +import { Map, List } from 'immutable'; +import { once } from 'lodash'; import uuid from 'uuid/v4'; +import { oneLine } from 'common-tags'; import { lengths, components, buttons } from 'netlify-cms-ui-default'; const MAX_DISPLAY_LENGTH = 50; @@ -64,6 +66,15 @@ function isMultiple(value) { return Array.isArray(value) || List.isList(value); } +const warnDeprecatedOptions = once(field => + console.warn(oneLine` + Netlify CMS config: ${field.get('name')} field: property "options" has been deprecated for the + ${field.get('widget')} widget and will be removed in the next major release. Rather than + \`field.options.media_library\`, apply media library options for this widget under + \`field.media_library\`. +`), +); + export default function withFileControl({ forImage } = {}) { return class FileControl extends React.Component { static propTypes = { @@ -126,12 +137,28 @@ export default function withFileControl({ forImage } = {}) { handleChange = e => { const { field, onOpenMediaLibrary, value } = this.props; e.preventDefault(); + let mediaLibraryFieldOptions; + + /** + * `options` hash as a general field property is deprecated, only used + * when external media libraries were first introduced. Not to be + * confused with `options` for the select widget, which serves a different + * purpose. + */ + if (field.hasIn(['options', 'media_library'])) { + warnDeprecatedOptions(field); + mediaLibraryFieldOptions = field.getIn(['options', 'media_library'], Map()); + } else { + mediaLibraryFieldOptions = field.get('media_library', Map()); + } + return onOpenMediaLibrary({ controlID: this.controlID, forImage, privateUpload: field.get('private'), value, - config: field.getIn(['options', 'media_library', 'config']), + allowMultiple: !!mediaLibraryFieldOptions.get('allow_multiple', true), + config: mediaLibraryFieldOptions.get('config'), }); }; diff --git a/packages/netlify-cms/src/media-libraries.js b/packages/netlify-cms/src/media-libraries.js index 1feab800..076e80af 100644 --- a/packages/netlify-cms/src/media-libraries.js +++ b/packages/netlify-cms/src/media-libraries.js @@ -1,6 +1,8 @@ import cms from 'netlify-cms-core/src'; import uploadcare from 'netlify-cms-media-library-uploadcare/src'; +import cloudinary from 'netlify-cms-media-library-cloudinary/src'; const { registerMediaLibrary } = cms; registerMediaLibrary(uploadcare); +registerMediaLibrary(cloudinary); diff --git a/website/content/docs/cloudinary.md b/website/content/docs/cloudinary.md new file mode 100644 index 00000000..fb7edfb0 --- /dev/null +++ b/website/content/docs/cloudinary.md @@ -0,0 +1,84 @@ +--- +title: Cloudinary +group: media +weight: '10' +--- +Cloudinary is a digital asset management platform with a broad feature set, including support for responsive image generation and url based image transformation. They also provide a powerful media library UI for managing assets, and tools for organizing your assets into a hierarchy. + +The Cloudinary media library integration for Netlify CMS uses Cloudinary's own media library interface within Netlify CMS. To get started, you'll need a Cloudinary account and Netlify CMS 2.3.0 or greater. + +## Creating a Cloudinary Account + +You can [sign up for Cloudinary](https://cloudinary.com/users/register/free) for free. Once you're logged in, you'll need to retrieve your Cloud name and API key from the upper left corner of the Cloudinary console. + +![Cloudinary console screenshot](/img/cloudinary-console-details.png) + +## Connecting Cloudinary to Netlify CMS + +To use the Cloudinary media library within Netlify CMS, you'll need to update your Netlify CMS configuration file with the information from your Cloudinary account: + +```yml +media_library: + name: cloudinary + config: + cloud_name: your_cloud_name + api_key: your_api_key +``` + +## Netlify CMS configuration options + +The following options are specific to the Netlify CMS integration for Cloudinary: + +* **`output_filename_only`**: _(default: `false`)_\ + By default, the value provided for a selected image is a complete URL for the asset on Cloudinary's CDN. Setting `output_filename_only` to `true` will instead produce just the filename (e.g. `image.jpg`). +* **`use_transformations`**: _(default: `true`)_\ + If `true`, uses derived url when available (the url will have image transformation segments included). Has no effect if `output_filename_only` is set to `true`. +* **`use_secure_url`**: _(default: `true`)_\ + Controls whether an `http` or `https` URL is provided. Has no effect if `output_filename_only` is set to `true`. + +## Cloudinary configuration options + +The Cloudinary media library integration can be configured in two ways: globally and per field. Global options will be overridden by field options. All options are listed in Cloudinary's [media library documentation](https://cloudinary.com/documentation/media_library_widget#3_set_the_configuration_options), but only the following options are available or recommended for the Netlify CMS integration: + +### Authentication + +* `cloud_name` +* `api_key` +* `username` _\- pre-fills a username in the Cloudinary login form_ + +### Media library behavior + +* `default_transformations` _\- only the first [image transformation](#image-transformations) is used, be sure to use the `Library` column transformation names from the_ [_transformation reference_]("https://cloudinary.com/documentation/image_transformation_reference") +* `max_files` +* `multiple` _\- has no impact on images inside the [markdown widget](/docs/widgets/#markdown)_ + +## Image transformations + +The Cloudinary integration allows images to be transformed in two ways: directly within Netlify CMS, and separately from the CMS via Cloudinary's [dynamic URL's](https://cloudinary.com/documentation/image_transformations#delivering_media_assets_using_dynamic_urls). If you transform images within the Cloudinary media library, the transformed image URL will be output by default. This gives the editor complete freedom to make changes to the image output. + +## Art direction and responsive images + +If you prefer to provide art direction so that images are transformed in a specific way, or dynamically retrieve images based on viewport size, you can do so by providing your own base Cloudinary URL and only storing the asset filenames in your content: + +1. Either globally or for specific fields, configure the Cloudinary extension to only output the asset filename: + +```yml +# global +media_library: + name: cloudinary + output_filename_only: true + +# field +fields: + - { name: image, widget: image, media_library: { output_filename_only: true } } +``` + +2. Provide a dynamic URL in the site template where the image is used: + +```hbs +{{! handlebars example }} + + +``` + +Your dynamic URL can be formed conditionally to provide any desired transformations - please see Cloudinary's [image transformation reference](https://cloudinary.com/documentation/image_transformation_reference) for available transformations. diff --git a/website/content/docs/uploadcare.md b/website/content/docs/uploadcare.md index 770c37cd..bdea92af 100644 --- a/website/content/docs/uploadcare.md +++ b/website/content/docs/uploadcare.md @@ -73,9 +73,8 @@ For example: name: cover label: Cover Image widget: image - options: - media_library: - config: - multiple: true - previewStep: false + media_library: + config: + multiple: true + previewStep: false ``` diff --git a/website/content/docs/widgets/file.md b/website/content/docs/widgets/file.md index 52ac4605..9b6d8cb4 100644 --- a/website/content/docs/widgets/file.md +++ b/website/content/docs/widgets/file.md @@ -7,13 +7,21 @@ The file widget allows editors to upload a file or select an existing one from t - **Name:** `file` - **UI:** file picker button opens media gallery -- **Data type:** file path string, based on `media_folder`/`public_folder` configuration +- **Data type:** file path string - **Options:** - `default`: accepts a file path string; defaults to null + - `media_library`: media library settings to apply when a media library is opened by the + current widget + - `allow_multiple`: _(default: `true`)_ when set to `false`, prevents multiple selection for any media library extension, but must be supported by the extension in use + - `config`: a configuration object that will be passed directly to the media library being + used - available options are determined by the library - **Example:** ```yaml - label: "Manual PDF" name: "manual_pdf" widget: "file" default: "/uploads/general-manual.pdf" + media_library: + config: + multiple: true ``` diff --git a/website/content/docs/widgets/image.md b/website/content/docs/widgets/image.md index 7a50cd03..ed0ef723 100644 --- a/website/content/docs/widgets/image.md +++ b/website/content/docs/widgets/image.md @@ -7,11 +7,12 @@ The image widget allows editors to upload an image or select an existing one fro - **Name:** `image` - **UI:** file picker button opens media gallery allowing image files (jpg, jpeg, webp, gif, png, bmp, tiff, svg) only; displays selected image thumbnail -- **Data type:** file path string, based on `media_folder`/`public_folder` configuration +- **Data type:** file path string - **Options:** - `default`: accepts a file path string; defaults to null - `media_library`: media library settings to apply when a media library is opened by the current widget + - `allow_multiple`: _(default: `true`)_ when set to `false`, prevents multiple selection for any media library extension, but must be supported by the extension in use - `config`: a configuration object that will be passed directly to the media library being used - available options are determined by the library - **Example:** @@ -22,6 +23,5 @@ The image widget allows editors to upload an image or select an existing one fro default: "/uploads/chocolate-dogecoin.jpg" media_library: config: - publicKey: "demopublickey" multiple: true ```