Feat: media folders templates (#3116)

* refactor: typescript backendHelper

* test: add string templating tests

* test: add createPreviewUrl invalid date test

* refactor: move all formatters to one file

* feat: support media folders templating

* feat: add filename and extension template variables

* feat: support paths in string templates

* docs: add media folder templating docs

* style(docs): remove line break
This commit is contained in:
Erez Rokah
2020-01-22 20:42:24 +02:00
committed by Shawn Erquhart
parent 4bc4490c6f
commit cf57da223d
20 changed files with 762 additions and 316 deletions

View File

@ -201,10 +201,11 @@ describe('entries', () => {
getAsset.mockReturnValue(() => asset);
const collection = Map();
await expect(getMediaAssets({ mediaFiles, collection })).resolves.toEqual([asset]);
const entry = Map({ mediaFiles });
await expect(getMediaAssets({ entry, collection })).resolves.toEqual([asset]);
expect(getAsset).toHaveBeenCalledTimes(1);
expect(getAsset).toHaveBeenCalledWith({ collection, path: 'path2' });
expect(getAsset).toHaveBeenCalledWith({ collection, path: 'path2', entry });
});
});
});

View File

@ -47,7 +47,7 @@ describe('media', () => {
});
selectMediaFilePath.mockReturnValue(path);
const payload = { collection: Map(), entryPath: 'entryPath', path };
const payload = { collection: Map(), entry: Map({ path: 'entryPath' }), path };
return store.dispatch(getAsset(payload)).then(result => {
const actions = store.getActions();
@ -58,7 +58,7 @@ describe('media', () => {
expect(selectMediaFilePath).toHaveBeenCalledWith(
store.getState().config,
payload.collection,
payload.entryPath,
payload.entry,
path,
);
});

View File

@ -371,10 +371,9 @@ export function persistUnpublishedEntry(collection: Collection, existingUnpublis
const entry = entryDraft.get('entry');
const assetProxies = await getMediaAssets({
getState,
mediaFiles: entry.get('mediaFiles'),
dispatch,
collection,
entryPath: entry.get('path'),
entry,
});
/**

View File

@ -12,15 +12,7 @@ import { createEntry, EntryValue } from '../valueObjects/Entry';
import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy';
import ValidationErrorTypes from '../constants/validationErrorTypes';
import { addAssets, getAsset } from './media';
import {
Collection,
EntryMap,
MediaFile,
State,
EntryFields,
EntryField,
MediaFileMap,
} from '../types/redux';
import { Collection, EntryMap, MediaFile, State, EntryFields, EntryField } from '../types/redux';
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction, Dispatch } from 'redux';
import { waitForMediaLibraryToLoad, loadMedia } from './mediaLibrary';
@ -539,22 +531,20 @@ export function createEmptyDraftData(fields: EntryFields, withNameKey = true) {
export async function getMediaAssets({
getState,
mediaFiles,
dispatch,
collection,
entryPath,
entry,
}: {
getState: () => State;
mediaFiles: List<MediaFileMap>;
collection: Collection;
entryPath: string;
entry: EntryMap;
dispatch: Dispatch;
}) {
const filesArray = mediaFiles.toJS() as MediaFile[];
const filesArray = entry.get('mediaFiles').toJS() as MediaFile[];
const assets = await Promise.all(
filesArray
.filter(file => file.draft)
.map(file => getAsset({ collection, entryPath, path: file.path })(dispatch, getState)),
.map(file => getAsset({ collection, entry, path: file.path })(dispatch, getState)),
);
return assets;
@ -592,10 +582,9 @@ export function persistEntry(collection: Collection) {
const entry = entryDraft.get('entry');
const assetProxies = await getMediaAssets({
getState,
mediaFiles: entry.get('mediaFiles'),
dispatch,
collection,
entryPath: entry.get('path'),
entry,
});
/**

View File

@ -1,5 +1,5 @@
import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy';
import { Collection, State } from '../types/redux';
import { Collection, State, EntryMap } from '../types/redux';
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { isAbsolutePath } from 'netlify-cms-lib-util';
@ -25,16 +25,16 @@ export function removeAsset(path: string) {
interface GetAssetArgs {
collection: Collection;
entryPath: string;
entry: EntryMap;
path: string;
}
export function getAsset({ collection, entryPath, path }: GetAssetArgs) {
export function getAsset({ collection, entry, path }: 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, entryPath, path);
const resolvedPath = selectMediaFilePath(state.config, collection, entry, path);
let asset = state.medias.get(resolvedPath);
if (asset) {

View File

@ -105,12 +105,13 @@ export function insertMedia(mediaPath: string | string[]) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const config = state.config;
const entry = state.entryDraft.get('entry');
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));
mediaPath = mediaPath.map(path => selectMediaFilePublicPath(config, collection, path, entry));
} else {
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string);
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry);
}
dispatch({ type: MEDIA_INSERT, payload: { mediaPath } });
};
@ -246,9 +247,8 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
throw new Error('The Private Upload option is only available for Asset Store Integration');
} else {
const entry = state.entryDraft.get('entry');
const entryPath = entry?.get('path');
const collection = state.collections.get(entry?.get('collection'));
const path = selectMediaFilePath(state.config, collection, entryPath, file.name);
const path = selectMediaFilePath(state.config, collection, entry, file.name);
assetProxy = createAssetProxy({
file,
path,

View File

@ -1,6 +1,5 @@
import { attempt, flatten, isError, trimStart, trimEnd, flow, partialRight, uniq } from 'lodash';
import { List } from 'immutable';
import { stripIndent } from 'common-tags';
import { attempt, flatten, isError, uniq } from 'lodash';
import { List, Map, fromJS } from 'immutable';
import * as fuzzy from 'fuzzy';
import { resolveFormat } from './formats/formats';
import { selectUseWorkflow } from './reducers/config';
@ -16,9 +15,9 @@ import {
selectInferedField,
} from './reducers/collections';
import { createEntry, EntryValue } from './valueObjects/Entry';
import { sanitizeSlug, sanitizeChar } from './lib/urlHelper';
import { sanitizeChar } from './lib/urlHelper';
import { getBackend } from './lib/registry';
import { commitMessageFormatter, slugFormatter, prepareSlug } from './lib/backendHelper';
import { commitMessageFormatter, slugFormatter, previewUrlFormatter } from './lib/formatters';
import {
localForage,
Cursor,
@ -34,18 +33,11 @@ import {
Config as ImplementationConfig,
} from 'netlify-cms-lib-util';
import { status } from './constants/publishModes';
import {
SLUG_MISSING_REQUIRED_DATE,
compileStringTemplate,
extractTemplateVars,
parseDateFromEntry,
dateParsers,
} from './lib/stringTemplate';
import { extractTemplateVars, dateParsers } from './lib/stringTemplate';
import {
Collection,
EntryMap,
Config,
SlugConfig,
FilterRule,
Collections,
EntryDraft,
@ -98,67 +90,6 @@ const sortByScore = (a: fuzzy.FilterResult<EntryValue>, b: fuzzy.FilterResult<En
return 0;
};
function createPreviewUrl(
baseUrl: string,
collection: Collection,
slug: string,
slugConfig: SlugConfig,
entry: EntryMap,
) {
/**
* Preview URL can't be created without `baseUrl`. This makes preview URLs
* optional for backends that don't support them.
*/
if (!baseUrl) {
return;
}
/**
* Without a `previewPath` for the collection (via config), the preview URL
* will be the URL provided by the backend.
*/
if (!collection.get('preview_path')) {
return baseUrl;
}
/**
* If a `previewPath` is provided for the collection, use it to construct the
* URL path.
*/
const basePath = trimEnd(baseUrl, '/');
const pathTemplate = collection.get('preview_path') as string;
const fields = entry.get('data');
const date = parseDateFromEntry(entry, collection, collection.get('preview_path_date_field'));
// Prepare and sanitize slug variables only, leave the rest of the
// `preview_path` template as is.
const processSegment = flow([
value => String(value),
prepareSlug,
partialRight(sanitizeSlug, slugConfig),
]);
let compiledPath;
try {
compiledPath = compileStringTemplate(pathTemplate, date, slug, fields, processSegment);
} catch (err) {
// Print an error and ignore `preview_path` if both:
// 1. Date is invalid (according to Moment), and
// 2. A date expression (eg. `{{year}}`) is used in `preview_path`
if (err.name === SLUG_MISSING_REQUIRED_DATE) {
console.error(stripIndent`
Collection "${collection.get('name')}" configuration error:
\`preview_path_date_field\` must be a field with a valid date. Ignoring \`preview_path\`.
`);
return basePath;
}
throw err;
}
const previewPath = trimStart(compiledPath, ' /');
return `${basePath}/${previewPath}`;
}
interface AuthStore {
retrieve: () => User;
store: (user: User) => void;
@ -297,7 +228,7 @@ export class Backend {
async generateUniqueSlug(
collection: Collection,
entryData: EntryMap,
entryData: Map<string, unknown>,
config: Config,
usedSlugs: List<string>,
) {
@ -551,20 +482,24 @@ export class Backend {
const integration = selectIntegration(state.integrations, null, 'assetStore');
const [loadedEntry, mediaFiles] = await Promise.all([
this.implementation.getEntry(path),
collection.has('media_folder') && !integration
? this.implementation.getMedia(selectMediaFolder(state.config, collection, path))
: Promise.resolve(state.mediaLibrary.get('files') || []),
]);
const loadedEntry = await this.implementation.getEntry(path);
const entry = createEntry(collection.get('name'), slug, loadedEntry.file.path, {
raw: loadedEntry.data,
label,
mediaFiles,
mediaFiles: [],
});
return this.entryWithFormat(collection)(entry);
const entryWithFormat = this.entryWithFormat(collection)(entry);
if (collection.has('media_folder') && !integration) {
entry.mediaFiles = await this.implementation.getMedia(
selectMediaFolder(state.config, collection, fromJS(entryWithFormat)),
);
} else {
entry.mediaFiles = state.mediaLibrary.get('files') || [];
}
return entryWithFormat;
}
getMedia() {
@ -656,7 +591,7 @@ export class Backend {
}
return {
url: createPreviewUrl(baseUrl, collection, slug, this.config.get('slug'), entry),
url: previewUrlFormatter(baseUrl, collection, slug, this.config.get('slug'), entry),
status: 'SUCCESS',
};
}
@ -705,7 +640,7 @@ export class Backend {
/**
* Create a URL using the collection `preview_path`, if provided.
*/
url: createPreviewUrl(deployPreview.url, collection, slug, this.config.get('slug'), entry),
url: previewUrlFormatter(deployPreview.url, collection, slug, this.config.get('slug'), entry),
/**
* Always capitalize the status for consistency.
*/
@ -725,8 +660,8 @@ export class Backend {
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
const parsedData = {
title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title'),
description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description!'),
title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title') as string,
description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description!') as string,
};
let entryObj: {
@ -756,7 +691,7 @@ export class Backend {
assetProxies.map(asset => {
// update media files path based on entry path
const oldPath = asset.path;
const newPath = selectMediaFilePath(config, collection, path, oldPath);
const newPath = selectMediaFilePath(config, collection, entryDraft.get('entry'), oldPath);
asset.path = newPath;
});
} else {
@ -807,8 +742,6 @@ export class Backend {
'uploadMedia',
config,
{
slug: '',
collection: '',
path: file.path,
authorLogin: user.login,
authorName: user.name,
@ -848,8 +781,6 @@ export class Backend {
'deleteMedia',
config,
{
slug: '',
collection: '',
path,
authorLogin: user.login,
authorName: user.name,

View File

@ -5,8 +5,7 @@ import { getAsset } from 'Actions/media';
import { Link } from 'react-router-dom';
import { colors, colorsRaw, components, lengths, Asset } from 'netlify-cms-ui-default';
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
import { compileStringTemplate, parseDateFromEntry } from 'Lib/stringTemplate';
import { selectIdentifier } from 'Reducers/collections';
import { summaryFormatter } from 'Lib/formatters';
const ListCard = styled.li`
${components.card};
@ -89,35 +88,19 @@ const CardImageAsset = ({ getAsset, image }) => {
};
const EntryCard = ({
collection,
entry,
inferedFields,
path,
summary,
image,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
boundGetAsset,
}) => {
const label = entry.get('label');
const entryData = entry.get('data');
const defaultTitle = label || entryData.get(inferedFields.titleField);
const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`;
const summary = collection.get('summary');
const date = parseDateFromEntry(entry, collection) || null;
const identifier = entryData.get(selectIdentifier(collection));
const title = summary
? compileStringTemplate(summary, date, identifier, entryData)
: defaultTitle;
let image = entryData.get(inferedFields.imageField);
if (image) {
image = encodeURI(image);
}
if (viewStyle === VIEW_STYLE_LIST) {
return (
<ListCard>
<ListCardLink to={path}>
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
<ListCardTitle>{title}</ListCardTitle>
<ListCardTitle>{summary}</ListCardTitle>
</ListCardLink>
</ListCard>
);
@ -129,7 +112,7 @@ const EntryCard = ({
<GridCardLink to={path}>
<CardBody hasImage={image}>
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
<CardHeading>{title}</CardHeading>
<CardHeading>{summary}</CardHeading>
</CardBody>
{image ? <CardImageAsset getAsset={boundGetAsset} image={image} /> : null}
</GridCardLink>
@ -138,9 +121,31 @@ const EntryCard = ({
}
};
const mapStateToProps = (state, ownProps) => {
const { entry, inferedFields, collection } = ownProps;
const label = entry.get('label');
const entryData = entry.get('data');
const defaultTitle = label || entryData.get(inferedFields.titleField);
const summaryTemplate = collection.get('summary');
const summary = summaryTemplate
? summaryFormatter(summaryTemplate, entry, collection)
: defaultTitle;
let image = entryData.get(inferedFields.imageField);
if (image) {
image = encodeURI(image);
}
return {
summary,
path: `/collections/${collection.get('name')}/entries/${entry.get('slug')}`,
image,
};
};
const mapDispatchToProps = {
boundGetAsset: (collection, entryPath) => (dispatch, getState) => path => {
return getAsset({ collection, entryPath, path })(dispatch, getState);
boundGetAsset: (collection, entry) => (dispatch, getState) => path => {
return getAsset({ collection, entry, path })(dispatch, getState);
},
};
@ -149,10 +154,10 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
...stateProps,
...dispatchProps,
...ownProps,
boundGetAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry.get('path')),
boundGetAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry),
};
};
const ConnectedEntryCard = connect(null, mapDispatchToProps, mergeProps)(EntryCard);
const ConnectedEntryCard = connect(mapStateToProps, mapDispatchToProps, mergeProps)(EntryCard);
export default ConnectedEntryCard;

View File

@ -515,8 +515,8 @@ const mapDispatchToProps = {
unpublishPublishedEntry,
deleteUnpublishedEntry,
logoutUser,
boundGetAsset: (collection, entryPath) => (dispatch, getState) => path => {
return getAsset({ collection, entryPath, path })(dispatch, getState);
boundGetAsset: (collection, entry) => (dispatch, getState) => path => {
return getAsset({ collection, entry, path })(dispatch, getState);
},
};
@ -527,7 +527,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
...ownProps,
boundGetAsset: dispatchProps.boundGetAsset(
stateProps.collection,
stateProps.entryDraft.getIn(['entry', 'path']),
stateProps.entryDraft.get('entry'),
),
};
};

View File

@ -261,7 +261,7 @@ class EditorControl extends React.Component {
const mapStateToProps = state => {
const { collections, entryDraft } = state;
const entryPath = entryDraft.getIn(['entry', 'path']);
const entry = entryDraft.get('entry');
const collection = collections.get(entryDraft.getIn(['entry', 'collection']));
return {
@ -269,7 +269,7 @@ const mapStateToProps = state => {
isFetching: state.search.get('isFetching'),
queryHits: state.search.get('queryHits'),
collection,
entryPath,
entry,
};
};
@ -286,8 +286,8 @@ const mapDispatchToProps = {
},
clearSearch,
clearFieldErrors,
boundGetAsset: (collection, entryPath) => (dispatch, getState) => path => {
return getAsset({ collection, entryPath, path })(dispatch, getState);
boundGetAsset: (collection, entry) => (dispatch, getState) => path => {
return getAsset({ collection, entry, path })(dispatch, getState);
},
};
@ -296,7 +296,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
...stateProps,
...dispatchProps,
...ownProps,
boundGetAsset: dispatchProps.boundGetAsset(stateProps.collection, stateProps.entryPath),
boundGetAsset: dispatchProps.boundGetAsset(stateProps.collection, stateProps.entry),
};
};

View File

@ -1,10 +1,17 @@
import { Map } from 'immutable';
import { commitMessageFormatter, prepareSlug, slugFormatter } from '../backendHelper';
import { Map, fromJS } from 'immutable';
import {
commitMessageFormatter,
prepareSlug,
slugFormatter,
previewUrlFormatter,
summaryFormatter,
folderFormatter,
} from '../formatters';
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.mock('Reducers/collections');
jest.mock('../../reducers/collections');
describe('backendHelper', () => {
describe('formatters', () => {
describe('commitMessageFormatter', () => {
const config = {
getIn: jest.fn(),
@ -205,7 +212,7 @@ describe('backendHelper', () => {
const date = new Date('2020-01-01');
jest.spyOn(global, 'Date').mockImplementation(() => date);
const { selectIdentifier } = require('Reducers/collections');
const { selectIdentifier } = require('../../reducers/collections');
beforeEach(() => {
jest.clearAllMocks();
@ -275,4 +282,151 @@ describe('backendHelper', () => {
).toBe('sub_dir/2020/2020-01-01-post-title.en');
});
});
describe('previewUrlFormatter', () => {
it('should return undefined when missing baseUrl', () => {
expect(previewUrlFormatter('')).toBeUndefined();
});
it('should return baseUrl for collection with no preview_path', () => {
expect(previewUrlFormatter('https://www.example.com', Map({}))).toBe(
'https://www.example.com',
);
});
it('should return preview url based on preview_path', () => {
const date = new Date('2020-01-02T13:28:27.679Z');
expect(
previewUrlFormatter(
'https://www.example.com',
Map({
preview_path: '{{year}}/{{slug}}/{{title}}/{{fields.slug}}',
preview_path_date_field: 'date',
}),
'backendSlug',
slugConfig,
Map({ data: Map({ date, slug: 'entrySlug', title: 'title' }) }),
),
).toBe('https://www.example.com/2020/backendslug/title/entryslug');
});
it('should compile filename and extension template values', () => {
expect(
previewUrlFormatter(
'https://www.example.com',
Map({
preview_path: 'posts/{{filename}}.{{extension}}',
}),
'backendSlug',
slugConfig,
Map({ data: Map({}), path: 'src/content/posts/title.md' }),
),
).toBe('https://www.example.com/posts/title.md');
});
it('should log error and ignore preview_path when date is missing', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
expect(
previewUrlFormatter(
'https://www.example.com',
Map({
name: 'posts',
preview_path: '{{year}}',
preview_path_date_field: 'date',
}),
'backendSlug',
slugConfig,
Map({ data: Map({}) }),
),
).toBe('https://www.example.com');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
'Collection "posts" configuration error:\n `preview_path_date_field` must be a field with a valid date. Ignoring `preview_path`.',
);
});
});
describe('summaryFormatter', () => {
it('should return summary from template', () => {
const { selectInferedField } = require('../../reducers/collections');
selectInferedField.mockReturnValue('date');
const date = new Date('2020-01-02T13:28:27.679Z');
const entry = fromJS({ data: { date, title: 'title' } });
const collection = fromJS({ fields: [{ name: 'date', widget: 'date' }] });
expect(summaryFormatter('{{title}}-{{year}}', entry, collection)).toBe('title-2020');
});
});
describe('folderFormatter', () => {
it('should return folder is entry is undefined', () => {
expect(folderFormatter('static/images', undefined)).toBe('static/images');
});
it('should return folder is entry data is undefined', () => {
expect(folderFormatter('static/images', Map({}))).toBe('static/images');
});
it('should return formatted folder', () => {
const { selectIdentifier } = require('../../reducers/collections');
selectIdentifier.mockReturnValue('title');
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({});
expect(
folderFormatter(
'../../../{{media_folder}}/{{category}}/{{slug}}',
entry,
collection,
'static/images',
'media_folder',
slugConfig,
),
).toBe('../../../static/images/hosting-and-deployment/deployment-with-nanobox');
});
it('should compile filename template value', () => {
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { category: 'Hosting And Deployment' },
});
const collection = fromJS({});
expect(
folderFormatter(
'../../../{{media_folder}}/{{category}}/{{filename}}',
entry,
collection,
'static/images',
'media_folder',
slugConfig,
),
).toBe('../../../static/images/hosting-and-deployment/deployment-with-nanobox');
});
it('should compile extension template value', () => {
const entry = fromJS({
path: 'content/en/hosting-and-deployment/deployment-with-nanobox.md',
data: { category: 'Hosting And Deployment' },
});
const collection = fromJS({});
expect(
folderFormatter(
'{{extension}}',
entry,
collection,
'static/images',
'media_folder',
slugConfig,
),
).toBe('md');
});
});
});

View File

@ -0,0 +1,115 @@
import { fromJS } from 'immutable';
import {
keyToPathArray,
compileStringTemplate,
parseDateFromEntry,
extractTemplateVars,
} from '../stringTemplate';
describe('stringTemplate', () => {
describe('keyToPathArray', () => {
it('should return array of length 1 with simple path', () => {
expect(keyToPathArray('category')).toEqual(['category']);
});
it('should return path array for complex path', () => {
expect(keyToPathArray('categories[0].title.subtitles[0].welcome[2]')).toEqual([
'categories',
'0',
'title',
'subtitles',
'0',
'welcome',
'2',
]);
});
});
describe('parseDateFromEntry', () => {
it('should infer date field and return entry date', () => {
const date = new Date().toISOString();
const entry = fromJS({ data: { date } });
const collection = fromJS({ fields: [{ name: 'date', widget: 'date' }] });
expect(parseDateFromEntry(entry, collection).toISOString()).toBe(date);
});
it('should use supplied date field and return entry date', () => {
const date = new Date().toISOString();
const entry = fromJS({ data: { preview_date: date } });
expect(parseDateFromEntry(entry, null, 'preview_date').toISOString()).toBe(date);
});
it('should return undefined on non existing date', () => {
const entry = fromJS({ data: {} });
const collection = fromJS({ fields: [{}] });
expect(parseDateFromEntry(entry, collection)).toBeUndefined();
});
it('should return undefined on invalid date', () => {
const entry = fromJS({ data: { date: '' } });
const collection = fromJS({ fields: [{ name: 'date', widget: 'date' }] });
expect(parseDateFromEntry(entry, collection)).toBeUndefined();
});
});
describe('extractTemplateVars', () => {
it('should extract template variables', () => {
expect(extractTemplateVars('{{slug}}-hello-{{date}}-world-{{fields.id}}')).toEqual([
'slug',
'date',
'fields.id',
]);
});
it('should return empty array on no matches', () => {
expect(extractTemplateVars('hello-world')).toEqual([]);
});
});
describe('compileStringTemplate', () => {
const date = new Date('2020-01-02T13:28:27.679Z');
it('should compile year variable', () => {
expect(compileStringTemplate('{{year}}', date)).toBe('2020');
});
it('should compile month variable', () => {
expect(compileStringTemplate('{{month}}', date)).toBe('01');
});
it('should compile day variable', () => {
expect(compileStringTemplate('{{day}}', date)).toBe('02');
});
it('should compile hour variable', () => {
expect(compileStringTemplate('{{hour}}', date)).toBe('13');
});
it('should compile minute variable', () => {
expect(compileStringTemplate('{{minute}}', date)).toBe('28');
});
it('should compile second variable', () => {
expect(compileStringTemplate('{{second}}', date)).toBe('27');
});
it('should error on missing date', () => {
expect(() => compileStringTemplate('{{year}}')).toThrowError();
});
it('return compiled template', () => {
expect(
compileStringTemplate(
'{{slug}}-{{year}}-{{fields.slug}}-{{title}}-{{date}}',
date,
'backendSlug',
fromJS({ slug: 'entrySlug', title: 'title', date }),
),
).toBe('backendSlug-2020-entrySlug-title-' + date.toString());
});
it('return apply processor to values', () => {
expect(
compileStringTemplate('{{slug}}', date, 'slug', fromJS({}), value => value.toUpperCase()),
).toBe('SLUG');
});
});
});

View File

@ -1,105 +0,0 @@
import { Map } from 'immutable';
import { flow, partialRight } from 'lodash';
import { sanitizeSlug } from 'Lib/urlHelper';
import { compileStringTemplate } from 'Lib/stringTemplate';
import { selectIdentifier } from 'Reducers/collections';
const commitMessageTemplates = Map({
create: 'Create {{collection}} “{{slug}}”',
update: 'Update {{collection}} “{{slug}}”',
delete: 'Delete {{collection}} “{{slug}}”',
uploadMedia: 'Upload “{{path}}”',
deleteMedia: 'Delete “{{path}}”',
openAuthoring: '{{message}}',
});
const variableRegex = /\{\{([^}]+)\}\}/g;
export const commitMessageFormatter = (
type,
config,
{ slug, path, collection, authorLogin, authorName },
isOpenAuthoring,
) => {
const templates = commitMessageTemplates.merge(
config.getIn(['backend', 'commit_messages'], Map()),
);
const commitMessage = templates.get(type).replace(variableRegex, (_, variable) => {
switch (variable) {
case 'slug':
return slug;
case 'path':
return path;
case 'collection':
return collection.get('label_singular') || collection.get('label');
default:
console.warn(`Ignoring unknown variable “${variable}” in commit message template.`);
return '';
}
});
if (!isOpenAuthoring) {
return commitMessage;
}
const message = templates.get('openAuthoring').replace(variableRegex, (_, variable) => {
switch (variable) {
case 'message':
return commitMessage;
case 'author-login':
return authorLogin || '';
case 'author-name':
return authorName || '';
default:
console.warn(`Ignoring unknown variable “${variable}” in open authoring message template.`);
return '';
}
});
return message;
};
export const prepareSlug = slug => {
return (
slug
.trim()
// Convert slug to lower-case
.toLocaleLowerCase()
// Remove single quotes.
.replace(/[']/g, '')
// Replace periods with dashes.
.replace(/[.]/g, '-')
);
};
export const slugFormatter = (collection, entryData, slugConfig) => {
const slugTemplate = collection.get('slug') || '{{slug}}';
const identifier = entryData.get(selectIdentifier(collection));
if (!identifier) {
throw new Error(
'Collection must have a field name that is a valid entry identifier, or must have `identifier_field` set',
);
}
const processSegment = flow([
value => String(value),
prepareSlug,
partialRight(sanitizeSlug, slugConfig),
]);
const date = new Date();
const slug = compileStringTemplate(slugTemplate, date, identifier, entryData, processSegment);
if (!collection.has('path')) {
return slug;
} else {
const pathTemplate = collection.get('path');
return compileStringTemplate(pathTemplate, date, slug, entryData, value =>
value === slug ? value : processSegment(value),
);
}
};

View File

@ -0,0 +1,237 @@
import { Map } from 'immutable';
import { flow, partialRight, trimEnd, trimStart } from 'lodash';
import { sanitizeSlug } from './urlHelper';
import {
compileStringTemplate,
parseDateFromEntry,
SLUG_MISSING_REQUIRED_DATE,
} from './stringTemplate';
import { selectIdentifier } from '../reducers/collections';
import { Collection, SlugConfig, Config, EntryMap } from '../types/redux';
import { stripIndent } from 'common-tags';
import { basename, fileExtension } from 'netlify-cms-lib-util';
const commitMessageTemplates = Map({
create: 'Create {{collection}} “{{slug}}”',
update: 'Update {{collection}} “{{slug}}”',
delete: 'Delete {{collection}} “{{slug}}”',
uploadMedia: 'Upload “{{path}}”',
deleteMedia: 'Delete “{{path}}”',
openAuthoring: '{{message}}',
});
const variableRegex = /\{\{([^}]+)\}\}/g;
type Options = {
slug?: string;
path?: string;
collection?: Collection;
authorLogin?: string;
authorName?: string;
};
export const commitMessageFormatter = (
type: string,
config: Config,
{ slug, path, collection, authorLogin, authorName }: Options,
isOpenAuthoring?: boolean,
) => {
const templates = commitMessageTemplates.merge(
config.getIn(['backend', 'commit_messages'], Map<string, string>()),
);
const commitMessage = templates.get(type).replace(variableRegex, (_, variable) => {
switch (variable) {
case 'slug':
return slug || '';
case 'path':
return path || '';
case 'collection':
return collection ? collection.get('label_singular') || collection.get('label') : '';
default:
console.warn(`Ignoring unknown variable “${variable}” in commit message template.`);
return '';
}
});
if (!isOpenAuthoring) {
return commitMessage;
}
const message = templates.get('openAuthoring').replace(variableRegex, (_, variable) => {
switch (variable) {
case 'message':
return commitMessage;
case 'author-login':
return authorLogin || '';
case 'author-name':
return authorName || '';
default:
console.warn(`Ignoring unknown variable “${variable}” in open authoring message template.`);
return '';
}
});
return message;
};
export const prepareSlug = (slug: string) => {
return (
slug
.trim()
// Convert slug to lower-case
.toLocaleLowerCase()
// Remove single quotes.
.replace(/[']/g, '')
// Replace periods with dashes.
.replace(/[.]/g, '-')
);
};
const getProcessSegment = (slugConfig: SlugConfig) =>
flow([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig)]);
export const slugFormatter = (
collection: Collection,
entryData: Map<string, unknown>,
slugConfig: SlugConfig,
) => {
const slugTemplate = collection.get('slug') || '{{slug}}';
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
const identifier = entryData.get(selectIdentifier(collection)) as string;
if (!identifier) {
throw new Error(
'Collection must have a field name that is a valid entry identifier, or must have `identifier_field` set',
);
}
const processSegment = getProcessSegment(slugConfig);
const date = new Date();
const slug = compileStringTemplate(slugTemplate, date, identifier, entryData, processSegment);
if (!collection.has('path')) {
return slug;
} else {
const pathTemplate = collection.get('path') as string;
return compileStringTemplate(pathTemplate, date, slug, entryData, (value: string) =>
value === slug ? value : processSegment(value),
);
}
};
const addFileTemplateFields = (entryPath: string, fields: Map<string, string>) => {
if (!entryPath) {
return fields;
}
const extension = fileExtension(entryPath);
const filename = basename(entryPath, `.${extension}`);
fields = fields.withMutations(map => {
map.set('filename', filename);
map.set('extension', extension);
});
return fields;
};
export const previewUrlFormatter = (
baseUrl: string,
collection: Collection,
slug: string,
slugConfig: SlugConfig,
entry: EntryMap,
) => {
/**
* Preview URL can't be created without `baseUrl`. This makes preview URLs
* optional for backends that don't support them.
*/
if (!baseUrl) {
return;
}
/**
* Without a `previewPath` for the collection (via config), the preview URL
* will be the URL provided by the backend.
*/
if (!collection.get('preview_path')) {
return baseUrl;
}
/**
* If a `previewPath` is provided for the collection, use it to construct the
* URL path.
*/
const basePath = trimEnd(baseUrl, '/');
const pathTemplate = collection.get('preview_path') as string;
let fields = entry.get('data') as Map<string, string>;
fields = addFileTemplateFields(entry.get('path'), fields);
const date = parseDateFromEntry(entry, collection, collection.get('preview_path_date_field'));
// Prepare and sanitize slug variables only, leave the rest of the
// `preview_path` template as is.
const processSegment = getProcessSegment(slugConfig);
let compiledPath;
try {
compiledPath = compileStringTemplate(pathTemplate, date, slug, fields, processSegment);
} catch (err) {
// Print an error and ignore `preview_path` if both:
// 1. Date is invalid (according to Moment), and
// 2. A date expression (eg. `{{year}}`) is used in `preview_path`
if (err.name === SLUG_MISSING_REQUIRED_DATE) {
console.error(stripIndent`
Collection "${collection.get('name')}" configuration error:
\`preview_path_date_field\` must be a field with a valid date. Ignoring \`preview_path\`.
`);
return basePath;
}
throw err;
}
const previewPath = trimStart(compiledPath, ' /');
return `${basePath}/${previewPath}`;
};
export const summaryFormatter = (
summaryTemplate: string,
entry: EntryMap,
collection: Collection,
) => {
const entryData = entry.get('data');
const date = parseDateFromEntry(entry, collection) || null;
const identifier = entryData.get(selectIdentifier(collection));
const summary = compileStringTemplate(summaryTemplate, date, identifier, entryData);
return summary;
};
export const folderFormatter = (
folderTemplate: string,
entry: EntryMap | undefined,
collection: Collection,
defaultFolder: string,
folderKey: string,
slugConfig: SlugConfig,
) => {
if (!entry || !entry.get('data')) {
return folderTemplate;
}
let fields = (entry.get('data') as Map<string, string>).set(folderKey, defaultFolder);
fields = addFileTemplateFields(entry.get('path'), fields);
const date = parseDateFromEntry(entry, collection) || null;
const identifier = fields.get(selectIdentifier(collection) as string);
const processSegment = getProcessSegment(slugConfig);
const mediaFolder = compileStringTemplate(
folderTemplate,
date,
identifier,
fields,
(value: string) => (value === defaultFolder ? defaultFolder : processSegment(value)),
);
return mediaFolder;
};

View File

@ -23,21 +23,40 @@ const FIELD_PREFIX = 'fields.';
const templateContentPattern = '[^}{]+';
const templateVariablePattern = `{{(${templateContentPattern})}}`;
export const keyToPathArray = (key: string) => {
const parts = [];
const separator = '';
const chars = key.split(separator);
let currentChar;
let currentStr = [];
while ((currentChar = chars.shift())) {
if (['[', ']', '.'].includes(currentChar)) {
if (currentStr.length > 0) {
parts.push(currentStr.join(separator));
}
currentStr = [];
} else {
currentStr.push(currentChar);
}
}
if (currentStr.length > 0) {
parts.push(currentStr.join(separator));
}
return parts;
};
// Allow `fields.` prefix in placeholder to override built in replacements
// like "slug" and "year" with values from fields of the same name.
function getExplicitFieldReplacement(key: string, data: Map<string, string>) {
function getExplicitFieldReplacement(key: string, data: Map<string, unknown>) {
if (!key.startsWith(FIELD_PREFIX)) {
return;
}
const fieldName = key.substring(FIELD_PREFIX.length);
return data.get(fieldName, '');
return data.getIn(keyToPathArray(fieldName), '') as string;
}
export function parseDateFromEntry(
entry: EntryMap,
collection: Collection,
fieldName: string | undefined,
) {
export function parseDateFromEntry(entry: EntryMap, collection: Collection, fieldName?: string) {
const dateFieldName = fieldName || selectInferedField(collection, 'date');
if (!dateFieldName) {
return;
@ -52,10 +71,10 @@ export function parseDateFromEntry(
export function compileStringTemplate(
template: string,
date: Date | undefined,
date: Date | undefined | null,
identifier = '',
data = Map<string, string>(),
processor: (value: string) => string,
data = Map<string, unknown>(),
processor?: (value: string) => string,
) {
let missingRequiredDate;
@ -63,36 +82,39 @@ export function compileStringTemplate(
// `null` as the date arg.
const useDate = date !== null;
const slug = template.replace(RegExp(templateVariablePattern, 'g'), (_, key: string) => {
let replacement;
const explicitFieldReplacement = getExplicitFieldReplacement(key, data);
const compiledString = template.replace(
RegExp(templateVariablePattern, 'g'),
(_, key: string) => {
let replacement;
const explicitFieldReplacement = getExplicitFieldReplacement(key, data);
if (explicitFieldReplacement) {
replacement = explicitFieldReplacement;
} else if (dateParsers[key] && !date) {
missingRequiredDate = true;
return '';
} else if (dateParsers[key]) {
replacement = dateParsers[key](date as Date);
} else if (key === 'slug') {
replacement = identifier;
} else {
replacement = data.get(key, '');
}
if (explicitFieldReplacement) {
replacement = explicitFieldReplacement;
} else if (dateParsers[key] && !date) {
missingRequiredDate = true;
return '';
} else if (dateParsers[key]) {
replacement = dateParsers[key](date as Date);
} else if (key === 'slug') {
replacement = identifier;
} else {
replacement = data.getIn(keyToPathArray(key), '') as string;
}
if (processor) {
return processor(replacement);
}
if (processor) {
return processor(replacement);
}
return replacement;
});
return replacement;
},
);
if (useDate && missingRequiredDate) {
const err = new Error();
err.name = SLUG_MISSING_REQUIRED_DATE;
throw err;
} else {
return slug;
return compiledString;
}
}

View File

@ -93,7 +93,7 @@ describe('entries', () => {
selectMediaFolder(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
'posts/title/index.md',
Map({ path: 'posts/title/index.md' }),
),
).toEqual('posts/title');
});
@ -103,10 +103,37 @@ describe('entries', () => {
selectMediaFolder(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '../' }),
'posts/title/index.md',
Map({ path: 'posts/title/index.md' }),
),
).toEqual('posts/');
});
it('should resolve 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',
media_folder: '../../../{{media_folder}}/{{category}}/{{slug}}',
fields: [{ name: 'title', widget: 'string' }],
});
expect(
selectMediaFolder(
fromJS({ media_folder: 'static/media', slug: slugConfig }),
collection,
entry,
),
).toEqual('static/media/hosting-and-deployment/deployment-with-nanobox');
});
});
describe('selectMediaFilePath', () => {
@ -149,7 +176,7 @@ describe('entries', () => {
selectMediaFilePath(
Map({ media_folder: 'static/media' }),
Map({ name: 'posts', folder: 'posts', media_folder: '../../static/media/' }),
'posts/title/index.md',
Map({ path: 'posts/title/index.md' }),
'image.png',
),
).toBe('static/media/image.png');
@ -188,5 +215,33 @@ describe('entries', () => {
),
).toBe('../../static/media/image.png');
});
it('should resolve 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',
public_folder: '/{{public_folder}}/{{category}}/{{slug}}',
fields: [{ name: 'title', widget: 'string' }],
});
expect(
selectMediaFilePublicPath(
fromJS({ public_folder: 'static/media', slug: slugConfig }),
collection,
'image.png',
entry,
),
).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
});
});
});

View File

@ -9,7 +9,6 @@ import {
ENTRIES_FAILURE,
ENTRY_DELETE_SUCCESS,
} from '../actions/entries';
import { SEARCH_ENTRIES_SUCCESS } from '../actions/search';
import {
EntriesAction,
@ -24,8 +23,10 @@ import {
EntryDeletePayload,
EntriesRequestPayload,
EntryDraft,
EntryMap,
} from '../types/redux';
import { isAbsolutePath, basename } from 'netlify-cms-lib-util/src';
import { folderFormatter } from '../lib/formatters';
import { isAbsolutePath, basename } from 'netlify-cms-lib-util';
let collection: string;
let loadedEntries: EntryObject[];
@ -140,14 +141,23 @@ const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES';
export const selectMediaFolder = (
config: Config,
collection: Collection | null,
entryPath: string | null,
entryMap: EntryMap | undefined,
) => {
let mediaFolder = config.get('media_folder');
if (collection && collection.has('media_folder')) {
const entryPath = entryMap?.get('path');
if (entryPath) {
const entryDir = dirname(entryPath);
mediaFolder = join(entryDir, collection.get('media_folder') as string);
const folder = folderFormatter(
collection.get('media_folder') as string,
entryMap as EntryMap,
collection,
mediaFolder,
'media_folder',
config.get('slug'),
);
mediaFolder = join(entryDir, folder as string);
} else {
mediaFolder = join(collection.get('folder') as string, DRAFT_MEDIA_FILES);
}
@ -159,7 +169,7 @@ export const selectMediaFolder = (
export const selectMediaFilePath = (
config: Config,
collection: Collection | null,
entryPath: string | null,
entryMap: EntryMap | undefined,
mediaPath: string,
) => {
if (isAbsolutePath(mediaPath)) {
@ -169,9 +179,9 @@ export const selectMediaFilePath = (
let mediaFolder;
if (mediaPath.startsWith('/')) {
// absolute media paths are not bound to a collection
mediaFolder = selectMediaFolder(config, null, null);
mediaFolder = selectMediaFolder(config, null, entryMap);
} else {
mediaFolder = selectMediaFolder(config, collection, entryPath);
mediaFolder = selectMediaFolder(config, collection, entryMap);
}
return join(mediaFolder, basename(mediaPath));
@ -181,6 +191,7 @@ export const selectMediaFilePublicPath = (
config: Config,
collection: Collection | null,
mediaPath: string,
entryMap: EntryMap | undefined,
) => {
if (isAbsolutePath(mediaPath)) {
return mediaPath;
@ -189,7 +200,14 @@ export const selectMediaFilePublicPath = (
let publicFolder = config.get('public_folder');
if (collection && collection.has('public_folder')) {
publicFolder = collection.get('public_folder') as string;
publicFolder = folderFormatter(
collection.get('public_folder') as string,
entryMap,
collection,
publicFolder,
'public_folder',
config.get('slug'),
);
}
return join(publicFolder, basename(mediaPath));

View File

@ -23,6 +23,7 @@ type BackendObject = {
gateway_url?: string;
large_media_url?: string;
use_large_media_transforms_in_media_library?: boolean;
commit_messages: Map<string, string>;
};
type Backend = StaticallyTypedRecord<Backend> & BackendObject;
@ -130,6 +131,9 @@ type CollectionObject = {
delete?: boolean;
identifier_field?: string;
path?: string;
slug?: string;
label_singular?: string;
label: string;
};
export type Collection = StaticallyTypedRecord<CollectionObject>;