Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow for relative paths of media files #2394

Merged
merged 9 commits into from
Aug 24, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .dependabot/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: 1

update_configs:
- package_manager: javascript
directory: /
update_schedule: weekly
automerged_updates:
- match:
dependency_type: all
update_type: in_range
version_requirement_updates: widen_ranges
- package_manager: javascript
directory: /website
update_schedule: weekly
automerged_updates:
- match:
dependency_type: all
update_type: in_range
version_requirement_updates: widen_ranges
3 changes: 3 additions & 0 deletions packages/netlify-cms-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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}');
}
});
});
});
31 changes: 28 additions & 3 deletions packages/netlify-cms-core/src/actions/mediaLibrary.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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();
};

Expand Down
1 change: 1 addition & 0 deletions packages/netlify-cms-core/src/constants/configSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions packages/netlify-cms-lib-util/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
91 changes: 90 additions & 1 deletion packages/netlify-cms-lib-util/src/__tests__/path.spec.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/netlify-cms-lib-util/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +35,7 @@ export const NetlifyCmsLibUtil = {
EDITORIAL_WORKFLOW_ERROR,
localForage,
resolvePath,
resolveMediaFilename,
basename,
fileExtensionWithSeparator,
fileExtension,
Expand All @@ -53,6 +60,7 @@ export {
EDITORIAL_WORKFLOW_ERROR,
localForage,
resolvePath,
resolveMediaFilename,
basename,
fileExtensionWithSeparator,
fileExtension,
Expand Down
33 changes: 33 additions & 0 deletions packages/netlify-cms-lib-util/src/path.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import getRelativePath from 'get-relative-path';

const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i');
const normalizePath = path => path.replace(/[\\/]+/g, '/');

Expand All @@ -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
Expand Down
Loading