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:
committed by
Shawn Erquhart
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>;
|
||||
|
Reference in New Issue
Block a user