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:
parent
4bc4490c6f
commit
cf57da223d
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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'),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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),
|
||||
);
|
||||
}
|
||||
};
|
237
packages/netlify-cms-core/src/lib/formatters.ts
Normal file
237
packages/netlify-cms-core/src/lib/formatters.ts
Normal 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;
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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));
|
||||
|
@ -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>;
|
||||
|
@ -55,7 +55,7 @@ You can now specify a `path` template (similar to the `slug` template) to contro
|
||||
|
||||
This allows saving content in subfolders, e.g. configuring `path: '{{year}}/{{slug}}'` will save the content under `2019/post-title.md`.
|
||||
|
||||
## Folder Collections Media Folder
|
||||
## Folder Collections Media and Public Folder
|
||||
|
||||
By default the CMS stores media files for all collections under a global `media_folder` directory as specified in the configuration.
|
||||
|
||||
@ -86,6 +86,7 @@ collections:
|
||||
folder: content/posts
|
||||
path: '{{slug}}/index'
|
||||
media_folder: ''
|
||||
public_folder: ''
|
||||
fields:
|
||||
- label: Title
|
||||
name: title
|
||||
@ -97,7 +98,7 @@ collections:
|
||||
|
||||
More specifically, saving a entry with a title of `example post` with an image named `image.png` will result in a directory structure of:
|
||||
|
||||
```
|
||||
```bash
|
||||
content
|
||||
posts
|
||||
example-post
|
||||
@ -109,6 +110,15 @@ And for the image field being populated with a value of `image.png`.
|
||||
|
||||
**Note: When specifying a `path` on a folder collection `media_folder` defaults to an empty string.**
|
||||
|
||||
**Available template tags:**
|
||||
|
||||
Supports all of the [`slug` templates](/docs/configuration-options#slug) and:
|
||||
|
||||
* `{{filename}}` The file name without the extension part.
|
||||
* `{{extension}}` The file extension.
|
||||
* `{{media_folder}}` The global `media_folder`.
|
||||
* `{{public_folder}}` The global `public_folder`.
|
||||
|
||||
## List Widget: Variable Types
|
||||
|
||||
Before this feature, the [list widget](/docs/widgets/#list) allowed a set of fields to be repeated, but every list item had the same set of fields available. With variable types, multiple named sets of fields can be defined, which opens the door to highly flexible content authoring (even page building) in Netlify CMS.
|
||||
@ -343,7 +353,6 @@ Template tags produce the following output:
|
||||
|
||||
- `{{author-name}}`: the full name of the author (might be empty based on the user's profile)
|
||||
|
||||
|
||||
## Image widget file size limit
|
||||
|
||||
You can set a limit to as what the maximum file size of a file is that users can upload directly into a image field.
|
||||
@ -359,4 +368,3 @@ Example config:
|
||||
config:
|
||||
max_file_size: 512000 # in bytes, only for default media library
|
||||
```
|
||||
|
||||
|
@ -256,6 +256,7 @@ would like to reference that field via `{{slug}}`, you can do so by adding the e
|
||||
prefix, eg. `{{fields.slug}}`.
|
||||
|
||||
**Available template tags:**
|
||||
|
||||
* Any field can be referenced by wrapping the field name in double curly braces, eg. `{{author}}`
|
||||
* `{{slug}}`: a url-safe version of the `title` field (or identifier field) for the file
|
||||
* `{{year}}`: 4-digit year of the file creation date
|
||||
@ -266,16 +267,19 @@ prefix, eg. `{{fields.slug}}`.
|
||||
* `{{second}}`: 2-digit second of the file creation date
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
slug: "{{year}}-{{month}}-{{day}}_{{slug}}"
|
||||
```
|
||||
|
||||
**Example using field names:**
|
||||
|
||||
```yaml
|
||||
slug: "{{year}}-{{month}}-{{day}}_{{title}}_{{some_other_field}}"
|
||||
```
|
||||
|
||||
**Example using field name that conflicts with a template tag:**
|
||||
|
||||
```yaml
|
||||
slug: "{{year}}-{{month}}-{{day}}_{{fields.slug}}"
|
||||
```
|
||||
@ -289,11 +293,14 @@ root of a deploy preview.
|
||||
**Available template tags:**
|
||||
|
||||
Template tags are the same as those for [slug](#slug), with the following exceptions:
|
||||
|
||||
* `{{slug}}` is the entire slug for the current entry (not just the url-safe identifier, as is the
|
||||
case with [`slug` configuration](#slug)
|
||||
* The date based template tags, such as `{{year}}` and `{{month}}`, are pulled from a date field in your entry, and may require additional configuration - see [`preview_path_date_field`](#preview_path_date_field) for details. If a date template tag is used and no date can be found, `preview_path` will be ignored.
|
||||
* `{{filename}}` The file name without the extension part.
|
||||
* `{{extension}}` The file extension.
|
||||
|
||||
**Example:**
|
||||
**Examples:**
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
@ -301,6 +308,12 @@ collections:
|
||||
preview_path: "blog/{{year}}/{{month}}/{{slug}}"
|
||||
```
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
- name: posts
|
||||
preview_path: "blog/{{year}}/{{month}}/{{filename}}.{{extension}}"
|
||||
```
|
||||
|
||||
### `preview_path_date_field`
|
||||
|
||||
The name of a date field for parsing date-based template tags from `preview_path`. If this field is
|
||||
@ -358,13 +371,13 @@ This setting changes options for the editor view of the collection. It has one o
|
||||
preview: false
|
||||
```
|
||||
|
||||
|
||||
### `summary`
|
||||
|
||||
This setting allows the customisation of the collection list view. Similar to the `slug` field, a string with templates can be used to include values of different fields, e.g. `{{title}}`.
|
||||
This option over-rides the default of `title` field and `identifier_field`.
|
||||
|
||||
**Example**
|
||||
|
||||
```yaml
|
||||
summary: "Version: {{version}} - {{title}}"
|
||||
```
|
||||
|
Loading…
x
Reference in New Issue
Block a user