feat: field based media/public folders (#3208)

This commit is contained in:
Erez Rokah 2020-02-10 18:05:47 +02:00 committed by GitHub
parent ee7445d49d
commit 97bc0c8dc4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 738 additions and 127 deletions

View File

@ -33,6 +33,7 @@ import {
getPointerFileForMediaFileObj,
getLargeMediaFilteredMediaFiles,
FetchError,
blobToFileObj,
} from 'netlify-cms-lib-util';
import NetlifyAuthenticator from 'netlify-cms-lib-auth';
import AuthenticationPage from './AuthenticationPage';
@ -325,7 +326,7 @@ export default class BitbucketBackend implements Implementation {
async getMediaFile(path: string) {
const name = basename(path);
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const fileObj = new File([blob], name);
const fileObj = blobToFileObj(name, blob);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(fileObj);
@ -423,7 +424,7 @@ export default class BitbucketBackend implements Implementation {
return getMediaAsBlob(file.path, null, readFile).then(blob => {
const name = basename(file.path);
const fileObj = new File([blob], name);
const fileObj = blobToFileObj(name, blob);
return {
id: file.path,
displayURL: URL.createObjectURL(fileObj),

View File

@ -24,6 +24,7 @@ import {
getPreviewStatus,
UnpublishedEntryMediaFile,
runWithLock,
blobToFileObj,
} from 'netlify-cms-lib-util';
import AuthenticationPage from './AuthenticationPage';
import { UsersGetAuthenticatedResponse as GitHubUser } from '@octokit/rest';
@ -324,7 +325,7 @@ export default class GitHub implements Implementation {
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const name = basename(path);
const fileObj = new File([blob], name);
const fileObj = blobToFileObj(name, blob);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(blob);
@ -388,7 +389,7 @@ export default class GitHub implements Implementation {
return getMediaAsBlob(file.path, file.id, readFile).then(blob => {
const name = basename(file.path);
const fileObj = new File([blob], name);
const fileObj = blobToFileObj(name, blob);
return {
id: file.id,
displayURL: URL.createObjectURL(fileObj),

View File

@ -25,10 +25,11 @@ import {
asyncLock,
AsyncLock,
runWithLock,
getBlobSHA,
blobToFileObj,
} from 'netlify-cms-lib-util';
import AuthenticationPage from './AuthenticationPage';
import API, { API_NAME } from './API';
import { getBlobSHA } from 'netlify-cms-lib-util/src';
const MAX_CONCURRENT_DOWNLOADS = 10;
@ -194,7 +195,7 @@ export default class GitLab implements Implementation {
async getMediaFile(path: string) {
const name = basename(path);
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
const fileObj = new File([blob], name);
const fileObj = blobToFileObj(name, blob);
const url = URL.createObjectURL(fileObj);
const id = await getBlobSHA(blob);
@ -275,7 +276,7 @@ export default class GitLab implements Implementation {
return getMediaAsBlob(file.path, null, readFile).then(blob => {
const name = basename(file.path);
const fileObj = new File([blob], name);
const fileObj = blobToFileObj(name, blob);
return {
id: file.path,
displayURL: URL.createObjectURL(fileObj),

View File

@ -60,6 +60,7 @@ describe('media', () => {
payload.collection,
payload.entry,
path,
undefined,
);
});
});

View File

@ -293,12 +293,19 @@ export function retrieveLocalBackup(collection: Collection, slug: string) {
const assetProxies: AssetProxy[] = await Promise.all(
mediaFiles.map(file => {
if (file.file || file.url) {
return createAssetProxy({ path: file.path, file: file.file, url: file.url });
return createAssetProxy({
path: file.path,
file: file.file,
url: file.url,
folder: file.folder,
});
} else {
return getAsset({ collection, entry: fromJS(entry), path: file.path })(
dispatch,
getState,
);
return getAsset({
collection,
entry: fromJS(entry),
path: file.path,
folder: file.folder,
})(dispatch, getState);
}
}),
);
@ -554,7 +561,9 @@ export async function getMediaAssets({
const assets = await Promise.all(
filesArray
.filter(file => file.draft)
.map(file => getAsset({ collection, entry, path: file.path })(dispatch, getState)),
.map(file =>
getAsset({ collection, entry, path: file.path, folder: file.folder })(dispatch, getState),
),
);
return assets;

View File

@ -27,14 +27,14 @@ interface GetAssetArgs {
collection: Collection;
entry: EntryMap;
path: string;
folder?: string;
}
export function getAsset({ collection, entry, path }: GetAssetArgs) {
export function getAsset({ collection, entry, path, folder }: GetAssetArgs) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
if (!path) return createAssetProxy({ path: '', file: new File([], 'empty') });
const state = getState();
const resolvedPath = selectMediaFilePath(state.config, collection, entry, path);
const resolvedPath = selectMediaFilePath(state.config, collection, entry, path, folder);
let asset = state.medias.get(resolvedPath);
if (asset) {

View File

@ -77,6 +77,8 @@ export function openMediaLibrary(
config?: Map<string, unknown>;
allowMultiple?: boolean;
forImage?: boolean;
mediaFolder?: string;
publicFolder?: string;
} = {},
) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
@ -101,7 +103,7 @@ export function closeMediaLibrary() {
};
}
export function insertMedia(mediaPath: string | string[]) {
export function insertMedia(mediaPath: string | string[], publicFolder: string | undefined) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const config = state.config;
@ -109,9 +111,17 @@ export function insertMedia(mediaPath: string | string[]) {
const collectionName = state.entryDraft.getIn(['entry', 'collection']);
const collection = state.collections.get(collectionName);
if (Array.isArray(mediaPath)) {
mediaPath = mediaPath.map(path => selectMediaFilePublicPath(config, collection, path, entry));
mediaPath = mediaPath.map(path =>
selectMediaFilePublicPath(config, collection, path, entry, publicFolder),
);
} else {
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry);
mediaPath = selectMediaFilePublicPath(
config,
collection,
mediaPath as string,
entry,
publicFolder,
);
}
dispatch({ type: MEDIA_INSERT, payload: { mediaPath } });
};
@ -191,12 +201,13 @@ function createMediaFileFromAsset({
size: file.size,
url: assetProxy.url,
path: assetProxy.path,
folder: assetProxy.folder,
};
return mediaFile;
}
export function persistMedia(file: File, opts: MediaOptions = {}) {
const { privateUpload } = opts;
const { privateUpload, mediaFolder } = opts;
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
@ -250,10 +261,11 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
} else {
const entry = state.entryDraft.get('entry');
const collection = state.collections.get(entry?.get('collection'));
const path = selectMediaFilePath(state.config, collection, entry, file.name);
const path = selectMediaFilePath(state.config, collection, entry, file.name, mediaFolder);
assetProxy = createAssetProxy({
file,
path,
folder: mediaFolder,
});
}
@ -397,6 +409,7 @@ export function mediaLoading(page: number) {
interface MediaOptions {
privateUpload?: boolean;
mediaFolder?: string;
}
export function mediaLoaded(files: ImplementationMediaFile[], opts: MediaOptions = {}) {

View File

@ -3,7 +3,7 @@ import { List, Map, fromJS } from 'immutable';
import * as fuzzy from 'fuzzy';
import { resolveFormat } from './formats/formats';
import { selectUseWorkflow } from './reducers/config';
import { selectMediaFilePath, selectMediaFolder, selectEntry } from './reducers/entries';
import { selectMediaFilePath, selectEntry } from './reducers/entries';
import { selectIntegration } from './reducers/integrations';
import {
selectEntrySlug,
@ -13,6 +13,7 @@ import {
selectAllowDeletion,
selectFolderEntryExtension,
selectInferedField,
selectMediaFolders,
} from './reducers/collections';
import { createEntry, EntryValue } from './valueObjects/Entry';
import { sanitizeChar } from './lib/urlHelper';
@ -31,6 +32,7 @@ import {
User,
getPathDepth,
Config as ImplementationConfig,
blobToFileObj,
} from 'netlify-cms-lib-util';
import { status } from './constants/publishModes';
import { extractTemplateVars, dateParsers } from './lib/stringTemplate';
@ -450,8 +452,7 @@ export class Backend {
// make sure to serialize the file
if (file.url?.startsWith('blob:')) {
const blob = await fetch(file.url as string).then(res => res.blob());
const options = file.name.match(/.svg$/) ? { type: 'image/svg+xml' } : {};
return { ...file, file: new File([blob], file.name, options) };
return { ...file, file: blobToFileObj(file.name, blob) };
}
return file;
}),
@ -492,10 +493,12 @@ export class Backend {
});
const entryWithFormat = this.entryWithFormat(collection)(entry);
if (collection.has('media_folder') && !integration) {
entry.mediaFiles = await this.implementation.getMedia(
selectMediaFolder(state.config, collection, fromJS(entryWithFormat)),
);
const mediaFolders = selectMediaFolders(state, collection, fromJS(entryWithFormat));
if (mediaFolders.length > 0 && !integration) {
entry.mediaFiles = [];
for (const folder of mediaFolders) {
entry.mediaFiles = [...entry.mediaFiles, ...(await this.implementation.getMedia(folder))];
}
} else {
entry.mediaFiles = state.mediaLibrary.get('files') || [];
}
@ -697,6 +700,7 @@ export class Backend {
collection,
entryDraft.get('entry').set('path', path),
oldPath,
asset.folder,
);
asset.path = newPath;
});

View File

@ -83,14 +83,15 @@ const CardImage = styled.div`
height: 150px;
`;
const CardImageAsset = ({ getAsset, image }) => {
return <Asset path={image} getAsset={getAsset} component={CardImage} />;
const CardImageAsset = ({ getAsset, image, folder }) => {
return <Asset folder={folder} path={image} getAsset={getAsset} component={CardImage} />;
};
const EntryCard = ({
path,
summary,
image,
imageFolder,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
boundGetAsset,
@ -114,7 +115,9 @@ const EntryCard = ({
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
<CardHeading>{summary}</CardHeading>
</CardBody>
{image ? <CardImageAsset getAsset={boundGetAsset} image={image} /> : null}
{image ? (
<CardImageAsset getAsset={boundGetAsset} image={image} folder={imageFolder} />
) : null}
</GridCardLink>
</GridCard>
);
@ -140,12 +143,16 @@ const mapStateToProps = (state, ownProps) => {
summary,
path: `/collections/${collection.get('name')}/entries/${entry.get('slug')}`,
image,
imageFolder: collection
.get('fields')
?.find(f => f.get('name') === inferedFields.imageField && f.get('widget') === 'image')
?.get('media_folder'),
};
};
const mapDispatchToProps = {
boundGetAsset: (collection, entry) => (dispatch, getState) => path => {
return getAsset({ collection, entry, path })(dispatch, getState);
boundGetAsset: (collection, entry) => (dispatch, getState) => (path, folder) => {
return getAsset({ collection, entry, path, folder })(dispatch, getState);
},
};

View File

@ -515,8 +515,8 @@ const mapDispatchToProps = {
unpublishPublishedEntry,
deleteUnpublishedEntry,
logoutUser,
boundGetAsset: (collection, entry) => (dispatch, getState) => path => {
return getAsset({ collection, entry, path })(dispatch, getState);
boundGetAsset: (collection, entry) => (dispatch, getState) => (path, folder) => {
return getAsset({ collection, entry, path, folder })(dispatch, getState);
},
};

View File

@ -288,8 +288,8 @@ const mapDispatchToProps = {
},
clearSearch,
clearFieldErrors,
boundGetAsset: (collection, entry) => (dispatch, getState) => path => {
return getAsset({ collection, entry, path })(dispatch, getState);
boundGetAsset: (collection, entry) => (dispatch, getState) => (path, folder) => {
return getAsset({ collection, entry, path, folder })(dispatch, getState);
},
};

View File

@ -160,7 +160,7 @@ class MediaLibrary extends React.Component {
event.persist();
event.stopPropagation();
event.preventDefault();
const { persistMedia, privateUpload, config, t } = this.props;
const { persistMedia, privateUpload, config, t, mediaFolder } = this.props;
const { files: fileList } = event.dataTransfer || event.target;
const files = [...fileList];
const file = files[0];
@ -173,7 +173,7 @@ class MediaLibrary extends React.Component {
}),
);
} else {
await persistMedia(file, { privateUpload });
await persistMedia(file, { privateUpload, mediaFolder });
this.setState({ selectedFile: this.props.files[0] });
@ -190,8 +190,8 @@ class MediaLibrary extends React.Component {
handleInsert = () => {
const { selectedFile } = this.state;
const { path } = selectedFile;
const { insertMedia } = this.props;
insertMedia(path);
const { insertMedia, publicFolder } = this.props;
insertMedia(path, publicFolder);
this.handleClose();
};
@ -332,6 +332,8 @@ const mapStateToProps = state => {
page: mediaLibrary.get('page'),
hasNextPage: mediaLibrary.get('hasNextPage'),
isPaginating: mediaLibrary.get('isPaginating'),
mediaFolder: mediaLibrary.get('mediaFolder'),
publicFolder: mediaLibrary.get('publicFolder'),
};
return { ...mediaLibraryProps };
};

View File

@ -19,7 +19,7 @@ interface MediaLibrary {
const initializeMediaLibrary = once(async function initializeMediaLibrary(name, options) {
const lib = (getMediaLibrary(name) as unknown) as MediaLibrary;
const handleInsert = (url: string) => store.dispatch(insertMedia(url));
const handleInsert = (url: string) => store.dispatch(insertMedia(url, undefined));
const instance = await lib.init({ options, handleInsert });
store.dispatch(createMediaLibrary(instance));
});

View File

@ -1,6 +1,12 @@
import { OrderedMap, fromJS } from 'immutable';
import { configLoaded } from 'Actions/config';
import collections, { selectAllowDeletion, selectEntryPath, selectEntrySlug } from '../collections';
import collections, {
selectAllowDeletion,
selectEntryPath,
selectEntrySlug,
selectFieldsMediaFolders,
selectMediaFolders,
} from '../collections';
import { FILES, FOLDER } from 'Constants/collectionTypes';
describe('collections', () => {
@ -76,4 +82,159 @@ describe('collections', () => {
).toBe('dir1/dir2/slug');
});
});
describe('selectFieldsMediaFolders', () => {
it('should return empty array for invalid collection', () => {
expect(selectFieldsMediaFolders(fromJS({}))).toEqual([]);
});
it('should return configs for folder collection', () => {
expect(
selectFieldsMediaFolders(
fromJS({
folder: 'posts',
fields: [
{
name: 'image',
media_folder: 'image_media_folder',
},
{
name: 'body',
media_folder: 'body_media_folder',
},
{
name: 'list_1',
field: {
name: 'list_1_item',
media_folder: 'list_1_item_media_folder',
},
},
{
name: 'list_2',
fields: [
{
name: 'list_2_item',
media_folder: 'list_2_item_media_folder',
},
],
},
],
}),
),
).toEqual([
'image_media_folder',
'body_media_folder',
'list_1_item_media_folder',
'list_2_item_media_folder',
]);
});
it('should return configs for files collection', () => {
expect(
selectFieldsMediaFolders(
fromJS({
files: [
{
fields: [
{
name: 'image',
media_folder: 'image_media_folder',
},
],
},
{
fields: [
{
name: 'body',
media_folder: 'body_media_folder',
},
],
},
{
fields: [
{
name: 'list_1',
field: {
name: 'list_1_item',
media_folder: 'list_1_item_media_folder',
},
},
],
},
{
fields: [
{
name: 'list_2',
fields: [
{
name: 'list_2_item',
media_folder: 'list_2_item_media_folder',
},
],
},
],
},
],
}),
),
).toEqual([
'image_media_folder',
'body_media_folder',
'list_1_item_media_folder',
'list_2_item_media_folder',
]);
});
});
describe('selectMediaFolders', () => {
const slug = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const config = fromJS({ slug });
it('should return fields and collection folder', () => {
expect(
selectMediaFolders(
{ config },
fromJS({
folder: 'posts',
media_folder: '/collection_media_folder',
fields: [
{
name: 'image',
media_folder: '/image_media_folder',
},
],
}),
fromJS({ slug: 'name', path: 'src/post/post1.md' }),
),
).toEqual(['collection_media_folder', 'image_media_folder']);
});
it('should return fields and collection folder', () => {
expect(
selectMediaFolders(
{ config },
fromJS({
files: [
{
name: 'name',
file: 'src/post/post1.md',
media_folder: '/file_media_folder',
fields: [
{
name: 'image',
media_folder: '/image_media_folder',
},
],
},
],
}),
fromJS({ slug: 'name', path: 'src/post/post1.md' }),
),
).toEqual(['file_media_folder', 'image_media_folder']);
});
});
});

View File

@ -74,16 +74,22 @@ describe('entries', () => {
describe('selectMediaFolder', () => {
it("should return global media folder when collection doesn't specify media_folder", () => {
expect(
selectMediaFolder(Map({ media_folder: 'static/media' }), Map({ name: 'posts' })),
selectMediaFolder(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts' }),
undefined,
undefined,
),
).toEqual('static/media');
});
it('should return draft media folder when collection specifies media_folder and entry path is null', () => {
it('should return draft media folder when collection specifies media_folder and entry is undefined', () => {
expect(
selectMediaFolder(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
null,
undefined,
undefined,
),
).toEqual('posts/DRAFT_MEDIA_FILES');
});
@ -94,6 +100,7 @@ describe('entries', () => {
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
Map({ path: 'posts/title/index.md' }),
undefined,
),
).toEqual('posts/title');
});
@ -104,11 +111,12 @@ describe('entries', () => {
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '../' }),
Map({ path: 'posts/title/index.md' }),
undefined,
),
).toEqual('posts/');
});
it('should return collection absolute media folder as is', () => {
it('should return collection absolute media folder without leading slash', () => {
expect(
selectMediaFolder(
Map({ media_folder: '/static/Images' }),
@ -118,8 +126,9 @@ describe('entries', () => {
media_folder: '/static/images/docs/getting-started',
}),
Map({ path: 'src/docs/getting-started/with-github.md' }),
undefined,
),
).toEqual('/static/images/docs/getting-started');
).toEqual('static/images/docs/getting-started');
});
it('should compile relative media folder template', () => {
@ -145,6 +154,7 @@ describe('entries', () => {
fromJS({ media_folder: 'static/media', slug: slugConfig }),
collection,
entry,
undefined,
),
).toEqual('static/media/hosting-and-deployment/deployment-with-nanobox');
});
@ -172,8 +182,85 @@ describe('entries', () => {
fromJS({ media_folder: '/static/images', slug: slugConfig }),
collection,
entry,
undefined,
),
).toEqual('/static/images/docs/extending');
).toEqual('static/images/docs/extending');
});
it('should compile field media folder template', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const collection = fromJS({
name: 'posts',
folder: 'content',
fields: [{ name: 'title', widget: 'string' }],
});
expect(
selectMediaFolder(
fromJS({ media_folder: 'static/media', slug: slugConfig }),
collection,
entry,
'../../../{{media_folder}}/{{category}}/{{slug}}',
),
).toEqual('static/media/hosting-and-deployment/deployment-with-nanobox');
});
it('should handle double slashes', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const collection = fromJS({
name: 'posts',
folder: 'content',
media_folder: '{{media_folder}}/blog',
fields: [{ name: 'title', widget: 'string' }],
});
expect(
selectMediaFolder(
fromJS({ media_folder: '/static/img/', slug: slugConfig }),
collection,
entry,
undefined,
),
).toEqual('static/img/blog');
expect(
selectMediaFolder(
fromJS({ media_folder: 'static/img/', slug: slugConfig }),
collection,
entry,
undefined,
),
).toEqual('content/en/hosting-and-deployment/static/img/blog');
});
it('should handle file media_folder', () => {
expect(
selectMediaFolder(
fromJS({ media_folder: 'static/media' }),
fromJS({ name: 'posts', files: [{ name: 'index', media_folder: '/static/images/' }] }),
fromJS({ path: 'posts/title/index.md', slug: 'index' }),
undefined,
),
).toBe('static/images/');
});
});
@ -189,8 +276,9 @@ describe('entries', () => {
selectMediaFilePath(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts' }),
null,
undefined,
'image.png',
undefined,
),
).toBe('static/media/image.png');
});
@ -200,8 +288,9 @@ describe('entries', () => {
selectMediaFilePath(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
null,
undefined,
'image.png',
undefined,
),
).toBe('posts/DRAFT_MEDIA_FILES/image.png');
});
@ -213,6 +302,19 @@ describe('entries', () => {
Map({ name: 'posts', folder: 'posts', media_folder: '../../static/media/' }),
Map({ path: 'posts/title/index.md' }),
'image.png',
undefined,
),
).toBe('static/media/image.png');
});
it('should handle field media_folder', () => {
expect(
selectMediaFilePath(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts' }),
Map({ path: 'posts/title/index.md' }),
'image.png',
'../../static/media/',
),
).toBe('static/media/image.png');
});
@ -227,7 +329,13 @@ describe('entries', () => {
it('should resolve path from public folder for collection with no media folder', () => {
expect(
selectMediaFilePublicPath(Map({ public_folder: '/media' }), null, '/media/image.png'),
selectMediaFilePublicPath(
Map({ public_folder: '/media' }),
null,
'/media/image.png',
undefined,
undefined,
),
).toBe('/media/image.png');
});
@ -237,6 +345,8 @@ describe('entries', () => {
Map({ public_folder: '/media' }),
Map({ name: 'posts', folder: 'posts', public_folder: '' }),
'image.png',
undefined,
undefined,
),
).toBe('image.png');
});
@ -247,11 +357,13 @@ describe('entries', () => {
Map({ public_folder: '/media' }),
Map({ name: 'posts', folder: 'posts', public_folder: '../../static/media/' }),
'image.png',
undefined,
undefined,
),
).toBe('../../static/media/image.png');
});
it('should compile public folder template', () => {
it('should compile collection public folder template', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
@ -275,8 +387,93 @@ describe('entries', () => {
collection,
'image.png',
entry,
undefined,
),
).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
});
it('should compile field public folder template', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const collection = fromJS({
name: 'posts',
folder: 'content',
fields: [{ name: 'title', widget: 'string' }],
});
expect(
selectMediaFilePublicPath(
fromJS({ public_folder: 'static/media', slug: slugConfig }),
collection,
'image.png',
entry,
'/{{public_folder}}/{{category}}/{{slug}}',
),
).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
});
it('should handle double slashes', () => {
const slugConfig = {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
};
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { title: 'Deployment With NanoBox', category: 'Hosting And Deployment' },
});
const collection = fromJS({
name: 'posts',
folder: 'content',
fields: [{ name: 'title', widget: 'string' }],
});
expect(
selectMediaFilePublicPath(
fromJS({ public_folder: 'static/media/', slug: slugConfig }),
collection,
'image.png',
entry,
'/{{public_folder}}/{{category}}/{{slug}}',
),
).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
});
it('should handle file public_folder', () => {
const entry = fromJS({
path: 'src/posts/index.md',
slug: 'index',
});
const collection = fromJS({
name: 'posts',
files: [
{
name: 'index',
public_folder: '/images',
fields: [{ name: 'title', widget: 'string' }],
},
],
});
expect(
selectMediaFilePublicPath(
fromJS({ public_folder: 'static/media/' }),
collection,
'image.png',
entry,
undefined,
),
).toBe('/images/image.png');
});
});
});

View File

@ -5,7 +5,15 @@ import { CONFIG_SUCCESS } from '../actions/config';
import { FILES, FOLDER } from '../constants/collectionTypes';
import { INFERABLE_FIELDS, IDENTIFIER_FIELDS } from '../constants/fieldInference';
import { formatExtensions } from '../formats/formats';
import { CollectionsAction, Collection, CollectionFiles, EntryField } from '../types/redux';
import {
CollectionsAction,
Collection,
CollectionFiles,
EntryField,
State,
EntryMap,
} from '../types/redux';
import { selectMediaFolder } from './entries';
const collections = (state = null, action: CollectionsAction) => {
switch (action.type) {
@ -106,6 +114,62 @@ const selectors = {
},
};
const getFieldsMediaFolders = (fields: EntryField[]) => {
const mediaFolders = fields.reduce((acc, f) => {
if (f.has('media_folder')) {
acc = [...acc, f.get('media_folder') as string];
}
if (f.has('fields')) {
const fields = f.get('fields')?.toArray() as EntryField[];
acc = [...acc, ...getFieldsMediaFolders(fields)];
}
if (f.has('field')) {
const field = f.get('field') as EntryField;
acc = [...acc, ...getFieldsMediaFolders([field])];
}
return acc;
}, [] as string[]);
return mediaFolders;
};
export const selectFieldsMediaFolders = (collection: Collection) => {
if (collection.has('folder')) {
const fields = collection.get('fields').toArray();
return getFieldsMediaFolders(fields);
} else if (collection.has('files')) {
const fields = collection
.get('files')
?.toArray()
.map(f => f.get('fields').toArray()) as EntryField[][];
const flattened = [] as EntryField[];
return getFieldsMediaFolders(flattened.concat(...fields));
}
return [];
};
export const selectMediaFolders = (state: State, collection: Collection, entry: EntryMap) => {
const fieldsFolders = selectFieldsMediaFolders(collection);
const folders = fieldsFolders.map(folder =>
selectMediaFolder(state.config, collection, entry, folder),
);
if (
collection.has('media_folder') ||
collection
.get('files')
?.find(file => file?.get('name') === entry?.get('slug') && file?.has('media_folder'))
) {
folders.unshift(selectMediaFolder(state.config, collection, entry, undefined));
}
return folders;
};
export const selectFields = (collection: Collection, slug: string) =>
selectors[collection.get('type')].fields(collection, slug);
export const selectFolderEntryExtension = (collection: Collection) =>

View File

@ -27,6 +27,7 @@ import {
} from '../types/redux';
import { folderFormatter } from '../lib/formatters';
import { isAbsolutePath, basename } from 'netlify-cms-lib-util';
import { trimStart } from 'lodash';
let collection: string;
let loadedEntries: EntryObject[];
@ -138,32 +139,67 @@ export const selectEntries = (state: Entries, collection: string) => {
const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES';
const getCustomFolder = (
name: 'media_folder' | 'public_folder',
collection: Collection | null,
slug: string | undefined,
fieldFolder: string | undefined,
) => {
if (!collection) {
return undefined;
}
if (fieldFolder !== undefined) {
return fieldFolder;
}
if (collection.has('files') && slug) {
const file = collection.get('files')?.find(f => f?.get('name') === slug);
if (file && file.has(name)) {
return file.get(name);
}
}
if (collection.has(name)) {
return collection.get(name);
}
return undefined;
};
export const selectMediaFolder = (
config: Config,
collection: Collection | null,
entryMap: EntryMap | undefined,
fieldMediaFolder: string | undefined,
) => {
let mediaFolder = config.get('media_folder');
if (collection && collection.has('media_folder')) {
const customFolder = getCustomFolder(
'media_folder',
collection,
entryMap?.get('slug'),
fieldMediaFolder,
);
if (customFolder !== undefined) {
const entryPath = entryMap?.get('path');
if (entryPath) {
const entryDir = dirname(entryPath);
const folder = folderFormatter(
collection.get('media_folder') as string,
customFolder,
entryMap as EntryMap,
collection,
collection!,
mediaFolder,
'media_folder',
config.get('slug'),
);
// return absolute paths as is
// return absolute paths as is without the leading '/'
if (folder.startsWith('/')) {
return folder;
mediaFolder = join(trimStart(folder, '/'));
} else {
mediaFolder = join(entryDir, folder as string);
}
mediaFolder = join(entryDir, folder as string);
} else {
mediaFolder = join(collection.get('folder') as string, DRAFT_MEDIA_FILES);
mediaFolder = join(collection!.get('folder') as string, DRAFT_MEDIA_FILES);
}
}
@ -175,12 +211,13 @@ export const selectMediaFilePath = (
collection: Collection | null,
entryMap: EntryMap | undefined,
mediaPath: string,
fieldMediaFolder: string | undefined,
) => {
if (isAbsolutePath(mediaPath)) {
return mediaPath;
}
const mediaFolder = selectMediaFolder(config, collection, entryMap);
const mediaFolder = selectMediaFolder(config, collection, entryMap, fieldMediaFolder);
return join(mediaFolder, basename(mediaPath));
};
@ -190,6 +227,7 @@ export const selectMediaFilePublicPath = (
collection: Collection | null,
mediaPath: string,
entryMap: EntryMap | undefined,
fieldPublicFolder: string | undefined,
) => {
if (isAbsolutePath(mediaPath)) {
return mediaPath;
@ -197,11 +235,18 @@ export const selectMediaFilePublicPath = (
let publicFolder = config.get('public_folder');
if (collection && collection.has('public_folder')) {
const customFolder = getCustomFolder(
'public_folder',
collection,
entryMap?.get('slug'),
fieldPublicFolder,
);
if (customFolder !== undefined) {
publicFolder = folderFormatter(
collection.get('public_folder') as string,
customFolder,
entryMap,
collection,
collection!,
publicFolder,
'public_folder',
config.get('slug'),

View File

@ -56,7 +56,14 @@ const mediaLibrary = (state = Map(defaultState), action: MediaLibraryAction) =>
map.set('showMediaButton', action.payload.enableStandalone());
});
case MEDIA_LIBRARY_OPEN: {
const { controlID, forImage, privateUpload, config } = action.payload;
const {
controlID,
forImage,
privateUpload,
config,
mediaFolder,
publicFolder,
} = action.payload;
const libConfig = config || Map();
const privateUploadChanged = state.get('privateUpload') !== privateUpload;
if (privateUploadChanged) {
@ -77,6 +84,8 @@ const mediaLibrary = (state = Map(defaultState), action: MediaLibraryAction) =>
map.set('canInsert', !!controlID);
map.set('privateUpload', privateUpload);
map.set('config', libConfig);
map.set('mediaFolder', mediaFolder);
map.set('publicFolder', publicFolder);
});
}
case MEDIA_LIBRARY_CLOSE:

View File

@ -94,6 +94,8 @@ export type EntryField = StaticallyTypedRecord<{
widget: string;
name: string;
default: string | null;
media_folder?: string;
public_folder?: string;
}>;
export type EntryFields = List<EntryField>;
@ -108,6 +110,8 @@ export type CollectionFile = StaticallyTypedRecord<{
name: string;
fields: EntryFields;
label: string;
media_folder?: string;
public_folder?: string;
}>;
export type CollectionFiles = List<CollectionFile>;
@ -305,6 +309,8 @@ export interface MediaLibraryAction extends Action<string> {
forImage: boolean;
privateUpload: boolean;
config: Map<string, string>;
mediaFolder?: string;
publicFolder?: string;
} & { mediaPath: string | string[] } & { page: number } & {
files: MediaFile[];
page: number;

View File

@ -2,17 +2,20 @@ interface AssetProxyArgs {
path: string;
url?: string;
file?: File;
folder?: string;
}
export default class AssetProxy {
url: string;
fileObj?: File;
path: string;
folder?: string;
constructor({ url, file, path }: AssetProxyArgs) {
constructor({ url, file, path, folder }: AssetProxyArgs) {
this.url = url ? url : window.URL.createObjectURL(file);
this.fileObj = file;
this.path = path;
this.folder = folder;
}
toString(): string {
@ -35,6 +38,6 @@ export default class AssetProxy {
}
}
export function createAssetProxy({ url, file, path }: AssetProxyArgs): AssetProxy {
return new AssetProxy({ url, file, path });
export function createAssetProxy({ url, file, path, folder }: AssetProxyArgs): AssetProxy {
return new AssetProxy({ url, file, path, folder });
}

View File

@ -12,8 +12,10 @@ const image = {
toBlock: ({ alt, image, title }) =>
`![${alt || ''}](${image || ''}${title ? ` "${title.replace(/"/g, '\\"')}"` : ''})`,
// eslint-disable-next-line react/display-name
toPreview: async ({ alt, image, title }, getAsset) => {
const src = await getAsset(image);
toPreview: async ({ alt, image, title }, getAsset, fields) => {
const imageField = fields?.find(f => f.get('widget') === 'image');
const folder = imageField?.get('media_folder');
const src = await getAsset(image, folder);
return <img src={src || ''} alt={alt || ''} title={title || ''} />;
},
pattern: /^!\[(.*)\]\((.*?)(\s"(.*)")?\)$/,

View File

@ -18,6 +18,7 @@ export interface ImplementationMediaFile {
draft?: boolean;
url?: string;
file?: File;
folder?: string;
}
export interface UnpublishedEntryMediaFile {
@ -264,6 +265,11 @@ export const unpublishedEntries = async (
}
};
export const blobToFileObj = (name: string, blob: Blob) => {
const options = name.match(/.svg$/) ? { type: 'image/svg+xml' } : {};
return new File([blob], name, options);
};
export const getMediaAsBlob = async (path: string, id: string | null, readFile: ReadFile) => {
let blob: Blob;
if (path.match(/.svg$/)) {

View File

@ -36,6 +36,7 @@ import {
runWithLock,
Config as C,
UnpublishedEntryMediaFile as UEMF,
blobToFileObj,
} from './implementation';
import {
readFile,
@ -136,6 +137,7 @@ export const NetlifyCmsLibUtil = {
getPointerFileForMediaFileObj,
branchFromContentKey,
contentKeyFromBranch,
blobToFileObj,
};
export {
APIError,
@ -186,4 +188,5 @@ export {
getPointerFileForMediaFileObj,
branchFromContentKey,
contentKeyFromBranch,
blobToFileObj,
};

View File

@ -6,6 +6,7 @@ class Asset extends React.Component {
path: PropTypes.string.isRequired,
getAsset: PropTypes.func.isRequired,
component: PropTypes.elementType.isRequired,
folder: PropTypes.string,
};
subscribed = true;
@ -14,13 +15,12 @@ class Asset extends React.Component {
value: null,
};
_fetchAsset() {
const { getAsset, path } = this.props;
getAsset(path).then(value => {
if (this.subscribed) {
this.setState({ value });
}
});
async _fetchAsset() {
const { getAsset, path, folder } = this.props;
const value = await getAsset(path, folder);
if (this.subscribed) {
this.setState({ value });
}
}
componentDidMount() {
@ -32,7 +32,11 @@ class Asset extends React.Component {
}
componentDidUpdate(prevProps) {
if (prevProps.path !== this.props.path || prevProps.getAsset !== this.props.getAsset) {
if (
prevProps.path !== this.props.path ||
prevProps.getAsset !== this.props.getAsset ||
prevProps.folder !== this.props.folder
) {
this._fetchAsset();
}
}

View File

@ -37,8 +37,8 @@ const Image = styled(({ value: src }) => <img src={src || ''} role="presentation
object-fit: contain;
`;
const ImageAsset = ({ getAsset, value }) => {
return <Asset path={value} getAsset={getAsset} component={Image} />;
const ImageAsset = ({ getAsset, value, folder }) => {
return <Asset folder={folder} path={value} getAsset={getAsset} component={Image} />;
};
const MultiImageWrapper = styled.div`
@ -175,6 +175,8 @@ export default function withFileControl({ forImage } = {}) {
value,
allowMultiple: !!mediaLibraryFieldOptions.get('allow_multiple', true),
config: mediaLibraryFieldOptions.get('config'),
mediaFolder: field.get('media_folder'),
publicFolder: field.get('public_folder'),
});
};
@ -218,13 +220,15 @@ export default function withFileControl({ forImage } = {}) {
};
renderImages = () => {
const { getAsset, value } = this.props;
const { getAsset, value, field } = this.props;
const folder = field.get('media_folder');
if (isMultiple(value)) {
return (
<MultiImageWrapper>
{value.map(val => (
<ImageWrapper key={val}>
<ImageAsset getAsset={getAsset} value={val} />
<ImageAsset getAsset={getAsset} value={val} folder={folder} />
</ImageWrapper>
))}
</MultiImageWrapper>
@ -232,7 +236,7 @@ export default function withFileControl({ forImage } = {}) {
}
return (
<ImageWrapper>
<ImageAsset getAsset={getAsset} value={value} />
<ImageAsset getAsset={getAsset} value={value} folder={folder} />
</ImageWrapper>
);
};

View File

@ -10,14 +10,23 @@ const StyledImage = styled(({ value: src }) => <img src={src || ''} role="presen
height: auto;
`;
const StyledImageAsset = ({ getAsset, value }) => {
return <Asset path={value} getAsset={getAsset} component={StyledImage} />;
const StyledImageAsset = ({ getAsset, value, field }) => {
return (
<Asset
folder={field.get('media_folder')}
path={value}
getAsset={getAsset}
component={StyledImage}
/>
);
};
const ImagePreviewContent = props => {
const { value, getAsset } = props;
const { value, getAsset, field } = props;
if (Array.isArray(value) || List.isList(value)) {
return value.map(val => <StyledImageAsset key={val} value={val} getAsset={getAsset} />);
return value.map(val => (
<StyledImageAsset key={val} value={val} getAsset={getAsset} field={field} />
));
}
return <StyledImageAsset {...props} />;
};

View File

@ -49,6 +49,38 @@ const createSlateValue = (rawValue, { voidCodeBlock }) => {
return Value.create({ document });
};
export const mergeMediaConfig = (editorComponents, field) => {
// merge editor media library config to image components
if (editorComponents.has('image')) {
const imageComponent = editorComponents.get('image');
const fields = imageComponent?.fields;
if (fields) {
imageComponent.fields = fields.update(
fields.findIndex(f => f.get('widget') === 'image'),
f => {
// merge `media_library` config
if (field.has('media_library')) {
f = f.set(
'media_library',
field.get('media_library').mergeDeep(f.get('media_library')),
);
}
// merge 'media_folder'
if (field.has('media_folder') && !f.has('media_folder')) {
f = f.set('media_folder', field.get('media_folder'));
}
// merge 'public_folder'
if (field.has('public_folder') && !f.has('public_folder')) {
f = f.set('public_folder', field.get('public_folder'));
}
return f;
},
);
}
}
};
export default class Editor extends React.Component {
constructor(props) {
super(props);
@ -59,6 +91,8 @@ export default class Editor extends React.Component {
this.codeBlockComponent || editorComponents.has('code-block')
? editorComponents
: editorComponents.set('code-block', { label: 'Code Block', type: 'code-block' });
mergeMediaConfig(this.editorComponents, this.props.field);
this.renderBlock = renderBlock({
classNameWrapper: props.className,
resolveWidget: props.resolveWidget,

View File

@ -0,0 +1,55 @@
import { Map, fromJS } from 'immutable';
import { mergeMediaConfig } from '../VisualEditor';
describe('VisualEditor', () => {
describe('mergeMediaConfig', () => {
it('should copy editor media settings to image component', () => {
const editorComponents = Map({
image: {
id: 'image',
label: 'Image',
type: 'shortcode',
icon: 'exclamation-triangle',
widget: 'object',
pattern: {},
fields: fromJS([
{
label: 'Image',
name: 'image',
widget: 'image',
media_library: { allow_multiple: false },
},
{ label: 'Alt Text', name: 'alt' },
{ label: 'Title', name: 'title' },
]),
},
});
const field = fromJS({
label: 'Body',
name: 'body',
widget: 'markdown',
media_folder: '/{{media_folder}}/posts/images/widget/body',
public_folder: '{{public_folder}}/posts/images/widget/body',
media_library: { config: { max_file_size: 1234 } },
});
mergeMediaConfig(editorComponents, field);
expect(editorComponents.get('image').fields).toEqual(
fromJS([
{
label: 'Image',
name: 'image',
widget: 'image',
media_library: { allow_multiple: false, config: { max_file_size: 1234 } },
media_folder: '/{{media_folder}}/posts/images/widget/body',
public_folder: '{{public_folder}}/posts/images/widget/body',
},
{ label: 'Alt Text', name: 'alt' },
{ label: 'Title', name: 'title' },
]),
);
});
});
});

View File

@ -53,9 +53,9 @@ export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWid
* Retrieve the shortcode preview component.
*/
async function getPreview(plugin, shortcodeData) {
const { toPreview, widget } = plugin;
const { toPreview, widget, fields } = plugin;
if (toPreview) {
return toPreview(shortcodeData, getAsset);
return toPreview(shortcodeData, getAsset, fields);
}
const preview = resolveWidget(widget);
return React.createElement(preview.preview, {

View File

@ -6538,11 +6538,6 @@ detect-indent@^5.0.0:
resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d"
integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50=
detect-libc@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
detect-newline@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2"
@ -9106,7 +9101,7 @@ husky@^3.0.9:
run-node "^1.0.0"
slash "^3.0.0"
iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@ -11802,15 +11797,6 @@ ncp@^2.0.0:
resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=
needle@^2.2.1:
version "2.4.0"
resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.0.tgz#6833e74975c444642590e15a750288c5f939b57c"
integrity sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==
dependencies:
debug "^3.2.6"
iconv-lite "^0.4.4"
sax "^1.2.4"
negotiator@0.6.2:
version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
@ -11974,22 +11960,6 @@ node-polyglot@^2.3.0:
string.prototype.trim "^1.1.2"
warning "^4.0.3"
node-pre-gyp@*:
version "0.14.0"
resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz#9a0596533b877289bcad4e143982ca3d904ddc83"
integrity sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA==
dependencies:
detect-libc "^1.0.2"
mkdirp "^0.5.1"
needle "^2.2.1"
nopt "^4.0.1"
npm-packlist "^1.1.6"
npmlog "^4.0.2"
rc "^1.2.7"
rimraf "^2.6.1"
semver "^5.3.0"
tar "^4.4.2"
node-releases@^1.1.29, node-releases@^1.1.47:
version "1.1.47"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.47.tgz#c59ef739a1fd7ecbd9f0b7cf5b7871e8a8b591e4"
@ -12111,7 +12081,7 @@ npm-normalize-package-bin@^1.0.0, npm-normalize-package-bin@^1.0.1:
semver "^5.6.0"
validate-npm-package-name "^3.0.0"
npm-packlist@^1.1.6, npm-packlist@^1.4.4:
npm-packlist@^1.4.4:
version "1.4.8"
resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e"
integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==
@ -12158,7 +12128,7 @@ npm-run-path@^4.0.0:
dependencies:
path-key "^3.0.0"
npmlog@^4.0.2, npmlog@^4.1.2:
npmlog@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
@ -13637,7 +13607,7 @@ rbush@2.0.2:
dependencies:
quickselect "^1.0.1"
rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
rc@^1.0.1, rc@^1.1.6:
version "1.2.8"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
@ -14793,7 +14763,7 @@ rimraf@2.6.3:
dependencies:
glob "^7.1.3"
rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@^2.7.1:
rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@^2.7.1:
version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
@ -14984,7 +14954,7 @@ semver-diff@^2.0.0:
dependencies:
semver "^5.0.3"
"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.4.1, semver@^5.5, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1:
"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.4.1, semver@^5.5, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
@ -16178,7 +16148,7 @@ tapable@^1.0.0, tapable@^1.1.3:
resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
tar@^4.4.10, tar@^4.4.12, tar@^4.4.2, tar@^4.4.8:
tar@^4.4.10, tar@^4.4.12, tar@^4.4.8:
version "4.4.13"
resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525"
integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==