diff --git a/packages/netlify-cms-core/package.json b/packages/netlify-cms-core/package.json index b31da8e4..a58aa487 100644 --- a/packages/netlify-cms-core/package.json +++ b/packages/netlify-cms-core/package.json @@ -78,5 +78,8 @@ "react": "^16.8.4", "react-dom": "^16.8.4", "react-immutable-proptypes": "^2.1.0" + }, + "devDependencies": { + "redux-mock-store": "^1.5.3" } } diff --git a/packages/netlify-cms-core/src/actions/__tests__/mediaLibrary.spec.js b/packages/netlify-cms-core/src/actions/__tests__/mediaLibrary.spec.js new file mode 100644 index 00000000..a3ce73d8 --- /dev/null +++ b/packages/netlify-cms-core/src/actions/__tests__/mediaLibrary.spec.js @@ -0,0 +1,94 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { fromJS } from 'immutable'; +import { insertMedia } from '../mediaLibrary'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +describe('mediaLibrary', () => { + describe('insertMedia', () => { + it('test public URL is returned directly', () => { + const store = mockStore({}); + store.dispatch(insertMedia({ url: '//localhost/foo.png' })); + expect(store.getActions()[0]).toEqual({ + type: 'MEDIA_INSERT', + payload: { mediaPath: '//localhost/foo.png' }, + }); + }); + + it('Test relative path resolution', () => { + const store = mockStore({ + config: fromJS({ + media_folder_relative: true, + media_folder: 'content/media', + }), + entryDraft: fromJS({ + entry: { + collection: 'blog-posts', + }, + }), + collections: fromJS({ + 'blog-posts': { + folder: 'content/blog/posts', + }, + }), + }); + store.dispatch(insertMedia({ name: 'foo.png' })); + expect(store.getActions()[0]).toEqual({ + type: 'MEDIA_INSERT', + payload: { mediaPath: '../../media/foo.png' }, + }); + }); + + // media_folder_relative will be used even if public_folder is specified + it('Test relative path resolution, with public folder specified', () => { + const store = mockStore({ + config: fromJS({ + media_folder_relative: true, + media_folder: 'content/media', + public_folder: '/static/assets/media', + }), + entryDraft: fromJS({ + entry: { + collection: 'blog-posts', + }, + }), + collections: fromJS({ + 'blog-posts': { + folder: 'content/blog/posts', + }, + }), + }); + store.dispatch(insertMedia({ name: 'foo.png' })); + expect(store.getActions()[0]).toEqual({ + type: 'MEDIA_INSERT', + payload: { mediaPath: '../../media/foo.png' }, + }); + }); + + it('Test public_folder resolution', () => { + const store = mockStore({ + config: fromJS({ + public_folder: '/static/assets/media', + }), + }); + store.dispatch(insertMedia({ name: 'foo.png' })); + expect(store.getActions()[0]).toEqual({ + type: 'MEDIA_INSERT', + payload: { mediaPath: '/static/assets/media/foo.png' }, + }); + }); + + it('Test incorrect usage', () => { + const store = mockStore(); + + try { + store.dispatch(insertMedia({ foo: 'foo.png' })); + throw new Error('Expected Exception'); + } catch (e) { + expect(e.message).toEqual('Incorrect usage, expected {url} or {file}'); + } + }); + }); +}); diff --git a/packages/netlify-cms-core/src/actions/mediaLibrary.js b/packages/netlify-cms-core/src/actions/mediaLibrary.js index b804a0cd..95a00559 100644 --- a/packages/netlify-cms-core/src/actions/mediaLibrary.js +++ b/packages/netlify-cms-core/src/actions/mediaLibrary.js @@ -1,6 +1,6 @@ import { Map } from 'immutable'; import { actions as notifActions } from 'redux-notifications'; -import { getBlobSHA } from 'netlify-cms-lib-util'; +import { resolveMediaFilename, getBlobSHA } from 'netlify-cms-lib-util'; import { currentBackend } from 'coreSrc/backend'; import { createAssetProxy } from 'ValueObjects/AssetProxy'; import { selectIntegration } from 'Reducers'; @@ -82,8 +82,33 @@ export function closeMediaLibrary() { }; } -export function insertMedia(mediaPath) { - return { type: MEDIA_INSERT, payload: { mediaPath } }; +export function insertMedia(media) { + return (dispatch, getState) => { + let mediaPath; + if (media.url) { + // media.url is public, and already resolved + mediaPath = media.url; + } else if (media.name) { + // media.name still needs to be resolved to the appropriate URL + const state = getState(); + const config = state.config; + if (config.get('media_folder_relative')) { + // the path is being resolved relatively + // and we need to know the path of the entry to resolve it + const mediaFolder = config.get('media_folder'); + const collection = state.entryDraft.getIn(['entry', 'collection']); + const collectionFolder = state.collections.getIn([collection, 'folder']); + mediaPath = resolveMediaFilename(media.name, { mediaFolder, collectionFolder }); + } else { + // the path is being resolved to a public URL + const publicFolder = config.get('public_folder'); + mediaPath = resolveMediaFilename(media.name, { publicFolder }); + } + } else { + throw new Error('Incorrect usage, expected {url} or {file}'); + } + dispatch({ type: MEDIA_INSERT, payload: { mediaPath } }); + }; } export function removeInsertedMedia(controlID) { diff --git a/packages/netlify-cms-core/src/components/MediaLibrary/MediaLibrary.js b/packages/netlify-cms-core/src/components/MediaLibrary/MediaLibrary.js index f4c82a65..f2ff65c6 100644 --- a/packages/netlify-cms-core/src/components/MediaLibrary/MediaLibrary.js +++ b/packages/netlify-cms-core/src/components/MediaLibrary/MediaLibrary.js @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { orderBy, map } from 'lodash'; import { translate } from 'react-polyglot'; import fuzzy from 'fuzzy'; -import { resolvePath, fileExtension } from 'netlify-cms-lib-util'; +import { fileExtension } from 'netlify-cms-lib-util'; import { loadMedia as loadMediaAction, persistMedia as persistMediaAction, @@ -56,7 +56,6 @@ class MediaLibrary extends React.Component { persistMedia: PropTypes.func.isRequired, deleteMedia: PropTypes.func.isRequired, insertMedia: PropTypes.func.isRequired, - publicFolder: PropTypes.string, closeMediaLibrary: PropTypes.func.isRequired, t: PropTypes.func.isRequired, }; @@ -189,9 +188,8 @@ class MediaLibrary extends React.Component { handleInsert = () => { const { selectedFile } = this.state; const { name, url, urlIsPublicPath } = selectedFile; - const { insertMedia, publicFolder } = this.props; - const publicPath = urlIsPublicPath ? url : resolvePath(name, publicFolder); - insertMedia(publicPath); + const { insertMedia } = this.props; + insertMedia(urlIsPublicPath ? { url } : { name }); this.handleClose(); }; diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index 456703f1..e8c8e8f0 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -40,6 +40,7 @@ const getConfigSchema = () => ({ show_preview_links: { type: 'boolean' }, media_folder: { type: 'string', examples: ['assets/uploads'] }, public_folder: { type: 'string', examples: ['/uploads'] }, + media_folder_relative: { type: 'boolean' }, media_library: { type: 'object', properties: { diff --git a/packages/netlify-cms-lib-util/package.json b/packages/netlify-cms-lib-util/package.json index 628a9072..07a0c412 100644 --- a/packages/netlify-cms-lib-util/package.json +++ b/packages/netlify-cms-lib-util/package.json @@ -17,6 +17,7 @@ "build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward" }, "dependencies": { + "get-relative-path": "^1.0.2", "js-sha256": "^0.9.0", "localforage": "^1.7.3" }, diff --git a/packages/netlify-cms-lib-util/src/__tests__/path.spec.js b/packages/netlify-cms-lib-util/src/__tests__/path.spec.js index 0d296541..981b4d78 100644 --- a/packages/netlify-cms-lib-util/src/__tests__/path.spec.js +++ b/packages/netlify-cms-lib-util/src/__tests__/path.spec.js @@ -1,4 +1,93 @@ -import { fileExtensionWithSeparator, fileExtension } from '../path'; +import { resolveMediaFilename, fileExtensionWithSeparator, fileExtension } from '../path'; + +describe('resolveMediaFilename', () => { + it('publicly Accessible URL, no slash', () => { + expect( + resolveMediaFilename('image.png', { + publicFolder: 'static/assets', + }), + ).toEqual('/static/assets/image.png'); + }); + + it('publicly Accessible URL, with slash', () => { + expect( + resolveMediaFilename('image.png', { + publicFolder: '/static/assets', + }), + ).toEqual('/static/assets/image.png'); + }); + + it('publicly Accessible URL, root', () => { + expect( + resolveMediaFilename('image.png', { + publicFolder: '/', + }), + ).toEqual('/image.png'); + }); + + it('relative URL, same folder', () => { + expect( + resolveMediaFilename('image.png', { + mediaFolder: '/content/posts', + collectionFolder: '/content/posts', + }), + ).toEqual('image.png'); + }); + + it('relative URL, same folder, with slash', () => { + expect( + resolveMediaFilename('image.png', { + mediaFolder: '/content/posts/', + collectionFolder: '/content/posts', + }), + ).toEqual('image.png'); + }); + + it('relative URL, same folder, with slashes', () => { + expect( + resolveMediaFilename('image.png', { + mediaFolder: '/content/posts/', + collectionFolder: '/content/posts/', + }), + ).toEqual('image.png'); + }); + + it('relative URL, sibling folder', () => { + expect( + resolveMediaFilename('image.png', { + mediaFolder: '/content/images/', + collectionFolder: '/content/posts/', + }), + ).toEqual('../images/image.png'); + }); + + it('relative URL, cousin folder', () => { + expect( + resolveMediaFilename('image.png', { + mediaFolder: '/content/images/pngs/', + collectionFolder: '/content/markdown/posts/', + }), + ).toEqual('../../images/pngs/image.png'); + }); + + it('relative URL, parent folder', () => { + expect( + resolveMediaFilename('image.png', { + mediaFolder: '/content/', + collectionFolder: '/content/posts', + }), + ).toEqual('../image.png'); + }); + + it('relative URL, child folder', () => { + expect( + resolveMediaFilename('image.png', { + mediaFolder: '/content/images', + collectionFolder: '/content/', + }), + ).toEqual('images/image.png'); + }); +}); describe('fileExtensionWithSeparator', () => { it('should return the extension of a file', () => { diff --git a/packages/netlify-cms-lib-util/src/index.js b/packages/netlify-cms-lib-util/src/index.js index 48283aa3..3e5b03b8 100644 --- a/packages/netlify-cms-lib-util/src/index.js +++ b/packages/netlify-cms-lib-util/src/index.js @@ -2,7 +2,13 @@ import APIError from './APIError'; import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from './Cursor'; import EditorialWorkflowError, { EDITORIAL_WORKFLOW_ERROR } from './EditorialWorkflowError'; import localForage from './localForage'; -import { resolvePath, basename, fileExtensionWithSeparator, fileExtension } from './path'; +import { + resolvePath, + resolveMediaFilename, + basename, + fileExtensionWithSeparator, + fileExtension, +} from './path'; import { filterPromises, filterPromisesWith, @@ -29,6 +35,7 @@ export const NetlifyCmsLibUtil = { EDITORIAL_WORKFLOW_ERROR, localForage, resolvePath, + resolveMediaFilename, basename, fileExtensionWithSeparator, fileExtension, @@ -53,6 +60,7 @@ export { EDITORIAL_WORKFLOW_ERROR, localForage, resolvePath, + resolveMediaFilename, basename, fileExtensionWithSeparator, fileExtension, diff --git a/packages/netlify-cms-lib-util/src/path.js b/packages/netlify-cms-lib-util/src/path.js index 11e0ccb7..4a8905de 100644 --- a/packages/netlify-cms-lib-util/src/path.js +++ b/packages/netlify-cms-lib-util/src/path.js @@ -1,3 +1,5 @@ +import getRelativePath from 'get-relative-path'; + const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i'); const normalizePath = path => path.replace(/[\\/]+/g, '/'); @@ -17,6 +19,37 @@ export function resolvePath(path, basePath) { return normalizePath(`/${path}`); } +/** + * Take a media filename and resolve it with respect to a + * certain collection entry, either as an absolute URL, or + * a path relative to the collection entry's folder. + * + * @param {*} filename the filename of the media item within the media_folder + * @param {*} options how the filename should be resolved, see examples below: + * + * @example Resolving to publicly accessible URL + * mediaFilenameToUse('image.jpg', { + * publicFolder: '/static/assets' // set by public_folder + * }) // -> "/static/assets/image.jpg" + * + * @example Resolving URL relatively to a specific collection entry + * mediaFilenameToUse('image.jpg', { + * mediaFolder: '/content/media', // set by media_folder + * collectionFolder: 'content/posts' + * }) // -> "../media/image.jpg" + * + */ +export function resolveMediaFilename(filename, options) { + if (options.publicFolder) { + return resolvePath(filename, options.publicFolder); + } else if (options.mediaFolder && options.collectionFolder) { + const media = normalizePath(`/${options.mediaFolder}/${filename}`); + const collection = normalizePath(`/${options.collectionFolder}/`); + return getRelativePath(collection, media); + } + throw new Error('incorrect usage'); +} + /** * Return the last portion of a path. Similar to the Unix basename command. * @example Usage example diff --git a/yarn.lock b/yarn.lock index 83df053e..f7ff249c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5695,6 +5695,11 @@ get-port@^3.2.0: resolved "https://registry.yarnpkg.com/get-port/-/get-port-3.2.0.tgz#dd7ce7de187c06c8bf353796ac71e099f0980ebc" integrity sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw= +get-relative-path@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-relative-path/-/get-relative-path-1.0.2.tgz#45c26fc4247f0c8541cec4b57d0de993ec965cda" + integrity sha512-dGkopYfmB4sXMTcZslq5SojEYakpdCSj/SVSHLhv7D6RBHzvDtd/3Q8lTEOAhVKxPPeAHu/YYkENbbz3PaH+8w== + get-stdin@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" @@ -7902,6 +7907,11 @@ lodash.ismatch@^4.4.0: resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc= +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + lodash.once@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" @@ -10469,6 +10479,13 @@ redent@^2.0.0: indent-string "^3.0.0" strip-indent "^2.0.0" +redux-mock-store@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.3.tgz#1f10528949b7ce8056c2532624f7cafa98576c6d" + integrity sha512-ryhkkb/4D4CUGpAV2ln1GOY/uh51aczjcRz9k2L2bPx/Xja3c5pSGJJPyR25GNVRXtKIExScdAgFdiXp68GmJA== + dependencies: + lodash.isplainobject "^4.0.6" + redux-notifications@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/redux-notifications/-/redux-notifications-4.0.1.tgz#66c9f11bb1eb375c633beaaf7378005eab303bfb"