feat: content in sub folders (#2897)
This commit is contained in:
committed by
Shawn Erquhart
parent
766ade1a3c
commit
afcfe5b6d5
@ -1,6 +1,7 @@
|
||||
import { resolveBackend, Backend } from '../backend';
|
||||
import registry from 'Lib/registry';
|
||||
import { Map, List } from 'immutable';
|
||||
import { FOLDER } from 'Constants/collectionTypes';
|
||||
import { Map, List, fromJS } from 'immutable';
|
||||
|
||||
jest.mock('Lib/registry');
|
||||
jest.mock('netlify-cms-lib-util');
|
||||
@ -378,4 +379,77 @@ describe('Backend', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateUniqueSlug', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should return unique slug when entry doesn't exist", async () => {
|
||||
const config = Map({});
|
||||
|
||||
const implementation = {
|
||||
init: jest.fn(() => implementation),
|
||||
getEntry: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
const collection = fromJS({
|
||||
name: 'posts',
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
},
|
||||
],
|
||||
type: FOLDER,
|
||||
folder: 'posts',
|
||||
slug: '{{slug}}',
|
||||
path: 'sub_dir/{{slug}}',
|
||||
});
|
||||
|
||||
const entry = Map({
|
||||
title: 'some post title',
|
||||
});
|
||||
|
||||
const backend = new Backend(implementation, { config, backendName: 'github' });
|
||||
|
||||
await expect(backend.generateUniqueSlug(collection, entry, Map({}), [])).resolves.toBe(
|
||||
'sub_dir/some-post-title',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return unique slug when entry exists', async () => {
|
||||
const config = Map({});
|
||||
|
||||
const implementation = {
|
||||
init: jest.fn(() => implementation),
|
||||
getEntry: jest.fn(),
|
||||
};
|
||||
|
||||
implementation.getEntry.mockResolvedValueOnce({ data: 'data' });
|
||||
implementation.getEntry.mockResolvedValueOnce();
|
||||
|
||||
const collection = fromJS({
|
||||
name: 'posts',
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
},
|
||||
],
|
||||
type: FOLDER,
|
||||
folder: 'posts',
|
||||
slug: '{{slug}}',
|
||||
path: 'sub_dir/{{slug}}',
|
||||
});
|
||||
|
||||
const entry = Map({
|
||||
title: 'some post title',
|
||||
});
|
||||
|
||||
const backend = new Backend(implementation, { config, backendName: 'github' });
|
||||
|
||||
await expect(backend.generateUniqueSlug(collection, entry, Map({}), [])).resolves.toBe(
|
||||
'sub_dir/some-post-title-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -11,13 +11,12 @@ import {
|
||||
selectAllowNewEntries,
|
||||
selectAllowDeletion,
|
||||
selectFolderEntryExtension,
|
||||
selectIdentifier,
|
||||
selectInferedField,
|
||||
} from 'Reducers/collections';
|
||||
import { createEntry } from 'ValueObjects/Entry';
|
||||
import { sanitizeSlug } from 'Lib/urlHelper';
|
||||
import { sanitizeSlug, sanitizeChar } from 'Lib/urlHelper';
|
||||
import { getBackend } from 'Lib/registry';
|
||||
import { commitMessageFormatter } from 'Lib/backendHelper';
|
||||
import { commitMessageFormatter, slugFormatter, prepareSlug } from 'Lib/backendHelper';
|
||||
import {
|
||||
localForage,
|
||||
Cursor,
|
||||
@ -50,21 +49,6 @@ export class LocalStorageAuthStore {
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSlug(slug) {
|
||||
return (
|
||||
slug
|
||||
.trim()
|
||||
// Convert slug to lower-case
|
||||
.toLocaleLowerCase()
|
||||
|
||||
// Remove single quotes.
|
||||
.replace(/[']/g, '')
|
||||
|
||||
// Replace periods with dashes.
|
||||
.replace(/[.]/g, '-')
|
||||
);
|
||||
}
|
||||
|
||||
function getEntryBackupKey(collectionName, slug) {
|
||||
const baseKey = 'backup';
|
||||
if (!collectionName) {
|
||||
@ -74,28 +58,6 @@ function getEntryBackupKey(collectionName, slug) {
|
||||
return `${baseKey}.${collectionName}${suffix}`;
|
||||
}
|
||||
|
||||
function slugFormatter(collection, entryData, slugConfig) {
|
||||
const template = 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',
|
||||
);
|
||||
}
|
||||
|
||||
// Pass entire slug through `prepareSlug` and `sanitizeSlug`.
|
||||
// TODO: only pass slug replacements through sanitizers, static portions of
|
||||
// the slug template should not be sanitized. (breaking change)
|
||||
const processSlug = flow([
|
||||
compileStringTemplate,
|
||||
prepareSlug,
|
||||
partialRight(sanitizeSlug, slugConfig),
|
||||
]);
|
||||
|
||||
return processSlug(template, new Date(), identifier, entryData);
|
||||
}
|
||||
|
||||
const extractSearchFields = searchFields => entry =>
|
||||
searchFields.reduce((acc, field) => {
|
||||
let nestedFields = field.split('.');
|
||||
@ -259,17 +221,15 @@ export class Backend {
|
||||
|
||||
async generateUniqueSlug(collection, entryData, slugConfig, usedSlugs) {
|
||||
const slug = slugFormatter(collection, entryData, slugConfig);
|
||||
const sanitizeEntrySlug = partialRight(sanitizeSlug, slugConfig);
|
||||
let i = 1;
|
||||
let sanitizedSlug = slug;
|
||||
let uniqueSlug = sanitizedSlug;
|
||||
let uniqueSlug = slug;
|
||||
|
||||
// Check for duplicate slug in loaded entities store first before repo
|
||||
while (
|
||||
usedSlugs.includes(uniqueSlug) ||
|
||||
(await this.entryExist(collection, selectEntryPath(collection, uniqueSlug), uniqueSlug))
|
||||
) {
|
||||
uniqueSlug = sanitizeEntrySlug(`${sanitizedSlug} ${i++}`);
|
||||
uniqueSlug = `${slug}${sanitizeChar(' ', slugConfig)}${i++}`;
|
||||
}
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
@ -94,7 +94,8 @@ const EntryCard = ({
|
||||
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 slug = entry.get('slug');
|
||||
const path = `/collections/${collection.get('name')}/entries/${encodeURIComponent(slug)}`;
|
||||
const summary = collection.get('summary');
|
||||
const date = parseDateFromEntry(entry, collection) || null;
|
||||
const identifier = entryData.get(selectIdentifier(collection));
|
||||
|
@ -40,7 +40,7 @@ const navigateCollection = collectionPath => history.push(`/collections/${collec
|
||||
const navigateToCollection = collectionName => navigateCollection(collectionName);
|
||||
const navigateToNewEntry = collectionName => navigateCollection(`${collectionName}/new`);
|
||||
const navigateToEntry = (collectionName, slug) =>
|
||||
navigateCollection(`${collectionName}/entries/${slug}`);
|
||||
navigateCollection(`${collectionName}/entries/${encodeURIComponent(slug)}`);
|
||||
|
||||
export class Editor extends React.Component {
|
||||
static propTypes = {
|
||||
@ -435,7 +435,7 @@ export class Editor extends React.Component {
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collections, entryDraft, auth, config, entries, globalUI } = state;
|
||||
const slug = ownProps.match.params.slug;
|
||||
const slug = ownProps.match.params.slug && decodeURIComponent(ownProps.match.params.slug);
|
||||
const collection = collections.get(ownProps.match.params.name);
|
||||
const collectionName = collection.get('name');
|
||||
const newEntry = ownProps.newRecord === true;
|
||||
|
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import { translate } from 'react-polyglot';
|
||||
import styled from '@emotion/styled';
|
||||
import { colors, transitions } from 'netlify-cms-ui-default';
|
||||
import { selectDraftPath } from 'Selectors/entryDraft';
|
||||
|
||||
const PathLabel = styled.label`
|
||||
color: ${colors.text};
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
padding-left: 5px;
|
||||
transition: all ${transitions.main};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const PathPreview = ({ value }) => {
|
||||
return <PathLabel data-testid="site_path-preview">{value}</PathLabel>;
|
||||
};
|
||||
|
||||
PathPreview.propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const collection = ownProps.collection;
|
||||
const entry = ownProps.entry;
|
||||
const newRecord = entry.get('newRecord');
|
||||
|
||||
const value = newRecord ? selectDraftPath(state, collection, entry) : entry.get('path');
|
||||
|
||||
return { value };
|
||||
};
|
||||
|
||||
const ConnectedPathPreview = connect(mapStateToProps)(translate()(PathPreview));
|
||||
|
||||
ConnectedPathPreview.propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
entry: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
export default ConnectedPathPreview;
|
@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { Map } from 'immutable';
|
||||
import { createStore } from 'redux';
|
||||
import { Provider } from 'react-redux';
|
||||
import { render } from '@testing-library/react';
|
||||
import ConnectedPathPreview, { PathPreview } from '../PathPreview';
|
||||
|
||||
jest.mock('Reducers', () => {
|
||||
return () => ({});
|
||||
});
|
||||
jest.mock('Selectors/entryDraft');
|
||||
jest.mock('react-polyglot', () => {
|
||||
return {
|
||||
translate: () => Component => props => <Component {...props} t={jest.fn(key => key)} />,
|
||||
};
|
||||
});
|
||||
|
||||
describe('PathPreview', () => {
|
||||
it('should render successfully and match snapshot', () => {
|
||||
const props = {
|
||||
value: 'posts/2019/index.md',
|
||||
t: jest.fn(key => key),
|
||||
};
|
||||
const { asFragment } = render(<PathPreview {...props} />);
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithRedux(ui, { initialState, store = createStore(() => ({}), initialState) } = {}) {
|
||||
return {
|
||||
...render(<Provider store={store}>{ui}</Provider>),
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ConnectedPathPreview', () => {
|
||||
const { selectDraftPath } = require('Selectors/entryDraft');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should use existing path when newRecord is false', () => {
|
||||
const props = {
|
||||
collection: Map({
|
||||
name: 'posts',
|
||||
}),
|
||||
entry: Map({
|
||||
newRecord: false,
|
||||
data: {},
|
||||
path: 'existing-path/index.md',
|
||||
}),
|
||||
};
|
||||
const { asFragment, getByTestId } = renderWithRedux(<ConnectedPathPreview {...props} />);
|
||||
|
||||
expect(selectDraftPath).toHaveBeenCalledTimes(0);
|
||||
|
||||
expect(getByTestId('site_path-preview')).toHaveTextContent('existing-path/index.md');
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should evaluate preview path when newRecord is true', () => {
|
||||
selectDraftPath.mockReturnValue('preview-path/index.md');
|
||||
const props = {
|
||||
collection: Map({
|
||||
name: 'posts',
|
||||
}),
|
||||
entry: Map({
|
||||
newRecord: true,
|
||||
data: {},
|
||||
}),
|
||||
};
|
||||
const { asFragment, getByTestId } = renderWithRedux(<ConnectedPathPreview {...props} />);
|
||||
|
||||
expect(selectDraftPath).toHaveBeenCalledTimes(1);
|
||||
expect(selectDraftPath).toHaveBeenCalledWith({}, props.collection, props.entry);
|
||||
|
||||
expect(getByTestId('site_path-preview')).toHaveTextContent('preview-path/index.md');
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
@ -0,0 +1,70 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ConnectedPathPreview should evaluate preview path when newRecord is true 1`] = `
|
||||
<DocumentFragment>
|
||||
.emotion-0 {
|
||||
color: #798291;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
padding-left: 5px;
|
||||
-webkit-transition: all .2s ease;
|
||||
transition: all .2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
<label
|
||||
class="emotion-0 emotion-1"
|
||||
data-testid="site_path-preview"
|
||||
>
|
||||
preview-path/index.md
|
||||
</label>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`ConnectedPathPreview should use existing path when newRecord is false 1`] = `
|
||||
<DocumentFragment>
|
||||
.emotion-0 {
|
||||
color: #798291;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
padding-left: 5px;
|
||||
-webkit-transition: all .2s ease;
|
||||
transition: all .2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
<label
|
||||
class="emotion-0 emotion-1"
|
||||
data-testid="site_path-preview"
|
||||
>
|
||||
existing-path/index.md
|
||||
</label>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`PathPreview should render successfully and match snapshot 1`] = `
|
||||
<DocumentFragment>
|
||||
.emotion-0 {
|
||||
color: #798291;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
padding-left: 5px;
|
||||
-webkit-transition: all .2s ease;
|
||||
transition: all .2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
<label
|
||||
class="emotion-0 emotion-1"
|
||||
data-testid="site_path-preview"
|
||||
>
|
||||
posts/2019/index.md
|
||||
</label>
|
||||
</DocumentFragment>
|
||||
`;
|
@ -14,7 +14,7 @@ function mapStateToProps(state, ownProps) {
|
||||
showDelete: !ownProps.newEntry && selectAllowDeletion(collection),
|
||||
};
|
||||
if (isEditorialWorkflow) {
|
||||
const slug = ownProps.match.params.slug;
|
||||
const slug = ownProps.match.params.slug && decodeURIComponent(ownProps.match.params.slug);
|
||||
const unpublishedEntry = selectUnpublishedEntry(state, collection.get('name'), slug);
|
||||
if (unpublishedEntry) {
|
||||
returnObj.unpublishedEntry = true;
|
||||
|
@ -204,11 +204,11 @@ class WorkflowList extends React.Component {
|
||||
<div>
|
||||
{entries.map(entry => {
|
||||
const timestamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('MMMM D');
|
||||
const slug = entry.get('slug');
|
||||
const editLink = `collections/${entry.getIn([
|
||||
'metaData',
|
||||
'collection',
|
||||
])}/entries/${entry.get('slug')}`;
|
||||
const slug = entry.get('slug');
|
||||
])}/entries/${encodeURIComponent(slug)}`;
|
||||
const ownStatus = entry.getIn(['metaData', 'status']);
|
||||
const collection = entry.getIn(['metaData', 'collection']);
|
||||
const isModification = entry.get('isModification');
|
||||
|
@ -101,6 +101,7 @@ const getConfigSchema = () => ({
|
||||
identifier_field: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
slug: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
preview_path: { type: 'string' },
|
||||
preview_path_date_field: { type: 'string' },
|
||||
create: { type: 'boolean' },
|
||||
|
@ -1,105 +1,162 @@
|
||||
import { Map } from 'immutable';
|
||||
import { commitMessageFormatter } from '../backendHelper';
|
||||
import { commitMessageFormatter, prepareSlug, slugFormatter } from '../backendHelper';
|
||||
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
jest.mock('Reducers/collections');
|
||||
|
||||
describe('commitMessageFormatter', () => {
|
||||
const config = {
|
||||
getIn: jest.fn(),
|
||||
};
|
||||
describe('backendHelper', () => {
|
||||
describe('commitMessageFormatter', () => {
|
||||
const config = {
|
||||
getIn: jest.fn(),
|
||||
};
|
||||
|
||||
const collection = {
|
||||
get: jest.fn().mockReturnValue('Collection'),
|
||||
};
|
||||
const collection = {
|
||||
get: jest.fn().mockReturnValue('Collection'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return default commit message on create', () => {
|
||||
expect(
|
||||
commitMessageFormatter('create', config, { slug: 'doc-slug', path: 'file-path', collection }),
|
||||
).toEqual('Create Collection “doc-slug”');
|
||||
});
|
||||
it('should return default commit message on create', () => {
|
||||
expect(
|
||||
commitMessageFormatter('create', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Create Collection “doc-slug”');
|
||||
});
|
||||
|
||||
it('should return default commit message on create', () => {
|
||||
collection.get.mockReturnValueOnce(undefined);
|
||||
collection.get.mockReturnValueOnce('Collections');
|
||||
it('should return default commit message on create', () => {
|
||||
collection.get.mockReturnValueOnce(undefined);
|
||||
collection.get.mockReturnValueOnce('Collections');
|
||||
|
||||
expect(
|
||||
commitMessageFormatter('update', config, { slug: 'doc-slug', path: 'file-path', collection }),
|
||||
).toEqual('Update Collections “doc-slug”');
|
||||
});
|
||||
expect(
|
||||
commitMessageFormatter('update', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Update Collections “doc-slug”');
|
||||
});
|
||||
|
||||
it('should return default commit message on delete', () => {
|
||||
expect(
|
||||
commitMessageFormatter('delete', config, { slug: 'doc-slug', path: 'file-path', collection }),
|
||||
).toEqual('Delete Collection “doc-slug”');
|
||||
});
|
||||
it('should return default commit message on delete', () => {
|
||||
expect(
|
||||
commitMessageFormatter('delete', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Delete Collection “doc-slug”');
|
||||
});
|
||||
|
||||
it('should return default commit message on uploadMedia', () => {
|
||||
expect(
|
||||
commitMessageFormatter('uploadMedia', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Upload “file-path”');
|
||||
});
|
||||
it('should return default commit message on uploadMedia', () => {
|
||||
expect(
|
||||
commitMessageFormatter('uploadMedia', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Upload “file-path”');
|
||||
});
|
||||
|
||||
it('should return default commit message on deleteMedia', () => {
|
||||
expect(
|
||||
commitMessageFormatter('deleteMedia', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Delete “file-path”');
|
||||
});
|
||||
it('should return default commit message on deleteMedia', () => {
|
||||
expect(
|
||||
commitMessageFormatter('deleteMedia', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Delete “file-path”');
|
||||
});
|
||||
|
||||
it('should log warning on unknown variable', () => {
|
||||
config.getIn.mockReturnValueOnce(
|
||||
Map({
|
||||
create: 'Create {{collection}} “{{slug}}” with "{{unknown variable}}"',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
commitMessageFormatter('create', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Create Collection “doc-slug” with ""');
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'Ignoring unknown variable “unknown variable” in commit message template.',
|
||||
);
|
||||
});
|
||||
it('should log warning on unknown variable', () => {
|
||||
config.getIn.mockReturnValueOnce(
|
||||
Map({
|
||||
create: 'Create {{collection}} “{{slug}}” with "{{unknown variable}}"',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
commitMessageFormatter('create', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Create Collection “doc-slug” with ""');
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'Ignoring unknown variable “unknown variable” in commit message template.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return custom commit message on update', () => {
|
||||
config.getIn.mockReturnValueOnce(
|
||||
Map({
|
||||
update: 'Custom commit message',
|
||||
}),
|
||||
);
|
||||
it('should return custom commit message on update', () => {
|
||||
config.getIn.mockReturnValueOnce(
|
||||
Map({
|
||||
update: 'Custom commit message',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
commitMessageFormatter('update', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Custom commit message');
|
||||
});
|
||||
expect(
|
||||
commitMessageFormatter('update', config, {
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
}),
|
||||
).toEqual('Custom commit message');
|
||||
});
|
||||
|
||||
it('should return custom open authoring message', () => {
|
||||
config.getIn.mockReturnValueOnce(
|
||||
Map({
|
||||
openAuthoring: '{{author-login}} - {{author-name}}: {{message}}',
|
||||
}),
|
||||
);
|
||||
it('should return custom open authoring message', () => {
|
||||
config.getIn.mockReturnValueOnce(
|
||||
Map({
|
||||
openAuthoring: '{{author-login}} - {{author-name}}: {{message}}',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
commitMessageFormatter(
|
||||
'create',
|
||||
config,
|
||||
{
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
authorLogin: 'user-login',
|
||||
authorName: 'Test User',
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual('user-login - Test User: Create Collection “doc-slug”');
|
||||
});
|
||||
|
||||
it('should use empty values if "authorLogin" and "authorName" are missing in open authoring message', () => {
|
||||
config.getIn.mockReturnValueOnce(
|
||||
Map({
|
||||
openAuthoring: '{{author-login}} - {{author-name}}: {{message}}',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
commitMessageFormatter(
|
||||
'create',
|
||||
config,
|
||||
{
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual(' - : Create Collection “doc-slug”');
|
||||
});
|
||||
|
||||
it('should log warning on unknown variable in open authoring template', () => {
|
||||
config.getIn.mockReturnValueOnce(
|
||||
Map({
|
||||
openAuthoring: '{{author-email}}: {{message}}',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
commitMessageFormatter(
|
||||
'create',
|
||||
config,
|
||||
@ -111,54 +168,101 @@ describe('commitMessageFormatter', () => {
|
||||
authorName: 'Test User',
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual('user-login - Test User: Create Collection “doc-slug”');
|
||||
);
|
||||
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'Ignoring unknown variable “author-email” in open authoring message template.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use empty values if "authorLogin" and "authorName" are missing in open authoring message', () => {
|
||||
config.getIn.mockReturnValueOnce(
|
||||
Map({
|
||||
openAuthoring: '{{author-login}} - {{author-name}}: {{message}}',
|
||||
}),
|
||||
);
|
||||
describe('prepareSlug', () => {
|
||||
it('should trim slug', () => {
|
||||
expect(prepareSlug(' slug ')).toBe('slug');
|
||||
});
|
||||
|
||||
expect(
|
||||
commitMessageFormatter(
|
||||
'create',
|
||||
config,
|
||||
{
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual(' - : Create Collection “doc-slug”');
|
||||
it('should lowercase slug', () => {
|
||||
expect(prepareSlug('Slug')).toBe('slug');
|
||||
});
|
||||
|
||||
it('should remove single quotes', () => {
|
||||
expect(prepareSlug(`sl'ug`)).toBe('slug');
|
||||
});
|
||||
|
||||
it('should replace periods with slashes', () => {
|
||||
expect(prepareSlug(`sl.ug`)).toBe('sl-ug');
|
||||
});
|
||||
});
|
||||
|
||||
it('should log warning on unknown variable in open authoring template', () => {
|
||||
config.getIn.mockReturnValueOnce(
|
||||
Map({
|
||||
openAuthoring: '{{author-email}}: {{message}}',
|
||||
}),
|
||||
);
|
||||
describe('slugFormatter', () => {
|
||||
const date = new Date('2020-01-01');
|
||||
jest.spyOn(global, 'Date').mockImplementation(() => date);
|
||||
|
||||
commitMessageFormatter(
|
||||
'create',
|
||||
config,
|
||||
{
|
||||
slug: 'doc-slug',
|
||||
path: 'file-path',
|
||||
collection,
|
||||
authorLogin: 'user-login',
|
||||
authorName: 'Test User',
|
||||
},
|
||||
true,
|
||||
);
|
||||
const { selectIdentifier } = require('Reducers/collections');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'Ignoring unknown variable “author-email” in open authoring message template.',
|
||||
);
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should format with default pattern', () => {
|
||||
selectIdentifier.mockReturnValueOnce('title');
|
||||
expect(slugFormatter(Map(), Map({ title: 'Post Title' }))).toBe('post-title');
|
||||
});
|
||||
|
||||
it('should format with date', () => {
|
||||
selectIdentifier.mockReturnValueOnce('title');
|
||||
|
||||
expect(
|
||||
slugFormatter(
|
||||
Map({ slug: '{{year}}-{{month}}-{{day}}_{{slug}}' }),
|
||||
Map({ title: 'Post Title' }),
|
||||
),
|
||||
).toBe('2020-01-01_post-title');
|
||||
});
|
||||
|
||||
it('should format with entry field', () => {
|
||||
selectIdentifier.mockReturnValueOnce('slug');
|
||||
|
||||
expect(
|
||||
slugFormatter(
|
||||
Map({ slug: '{{fields.slug}}' }),
|
||||
Map({ title: 'Post Title', slug: 'entry-slug' }),
|
||||
),
|
||||
).toBe('entry-slug');
|
||||
});
|
||||
|
||||
it('should return slug', () => {
|
||||
selectIdentifier.mockReturnValueOnce('title');
|
||||
|
||||
expect(slugFormatter(Map({ slug: '{{slug}}' }), Map({ title: 'Post Title' }))).toBe(
|
||||
'post-title',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return slug with path', () => {
|
||||
selectIdentifier.mockReturnValueOnce('title');
|
||||
|
||||
expect(
|
||||
slugFormatter(
|
||||
Map({ slug: '{{year}}-{{month}}-{{day}}-{{slug}}', path: 'sub_dir/{{year}}/{{slug}}' }),
|
||||
Map({ title: 'Post Title' }),
|
||||
),
|
||||
).toBe('sub_dir/2020/2020-01-01-post-title');
|
||||
});
|
||||
|
||||
it('should only sanitize template variables', () => {
|
||||
selectIdentifier.mockReturnValueOnce('title');
|
||||
|
||||
expect(
|
||||
slugFormatter(
|
||||
Map({
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}.en',
|
||||
path: 'sub_dir/{{year}}/{{slug}}',
|
||||
}),
|
||||
Map({ title: 'Post Title' }),
|
||||
),
|
||||
).toBe('sub_dir/2020/2020-01-01-post-title.en');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Map } from 'immutable';
|
||||
import { sanitizeURI, sanitizeSlug } from '../urlHelper';
|
||||
import { sanitizeURI, sanitizeSlug, sanitizeChar } from '../urlHelper';
|
||||
|
||||
describe('sanitizeURI', () => {
|
||||
// `sanitizeURI` tests from RFC 3987
|
||||
@ -113,3 +113,13 @@ describe('sanitizeSlug', () => {
|
||||
expect(sanitizeSlug('test test ', Map({ sanitize_replacement: '_' }))).toEqual('test_test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeChar', () => {
|
||||
it('should sanitize whitespace with default replacement', () => {
|
||||
expect(sanitizeChar(' ', Map())).toBe('-');
|
||||
});
|
||||
|
||||
it('should sanitize whitespace with custom replacement', () => {
|
||||
expect(sanitizeChar(' ', Map({ sanitize_replacement: '_' }))).toBe('_');
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,8 @@
|
||||
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}}”',
|
||||
@ -55,3 +59,47 @@ export const commitMessageFormatter = (
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -38,16 +38,10 @@ const uriChars = /[\w\-.~]/i;
|
||||
const ucsChars = /[\xA0-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFEF}\u{10000}-\u{1FFFD}\u{20000}-\u{2FFFD}\u{30000}-\u{3FFFD}\u{40000}-\u{4FFFD}\u{50000}-\u{5FFFD}\u{60000}-\u{6FFFD}\u{70000}-\u{7FFFD}\u{80000}-\u{8FFFD}\u{90000}-\u{9FFFD}\u{A0000}-\u{AFFFD}\u{B0000}-\u{BFFFD}\u{C0000}-\u{CFFFD}\u{D0000}-\u{DFFFD}\u{E1000}-\u{EFFFD}]/u;
|
||||
const validURIChar = char => uriChars.test(char);
|
||||
const validIRIChar = char => uriChars.test(char) || ucsChars.test(char);
|
||||
// `sanitizeURI` does not actually URI-encode the chars (that is the browser's and server's job), just removes the ones that are not allowed.
|
||||
export function sanitizeURI(str, { replacement = '', encoding = 'unicode' } = {}) {
|
||||
if (!isString(str)) {
|
||||
throw new Error('The input slug must be a string.');
|
||||
}
|
||||
if (!isString(replacement)) {
|
||||
throw new Error('`options.replacement` must be a string.');
|
||||
}
|
||||
|
||||
export function getCharReplacer(encoding, replacement) {
|
||||
let validChar;
|
||||
|
||||
if (encoding === 'unicode') {
|
||||
validChar = validIRIChar;
|
||||
} else if (encoding === 'ascii') {
|
||||
@ -61,13 +55,31 @@ export function sanitizeURI(str, { replacement = '', encoding = 'unicode' } = {}
|
||||
throw new Error('The replacement character(s) (options.replacement) is itself unsafe.');
|
||||
}
|
||||
|
||||
return char => (validChar(char) ? char : replacement);
|
||||
}
|
||||
// `sanitizeURI` does not actually URI-encode the chars (that is the browser's and server's job), just removes the ones that are not allowed.
|
||||
export function sanitizeURI(str, { replacement = '', encoding = 'unicode' } = {}) {
|
||||
if (!isString(str)) {
|
||||
throw new Error('The input slug must be a string.');
|
||||
}
|
||||
if (!isString(replacement)) {
|
||||
throw new Error('`options.replacement` must be a string.');
|
||||
}
|
||||
|
||||
// `Array.from` must be used instead of `String.split` because
|
||||
// `split` converts things like emojis into UTF-16 surrogate pairs.
|
||||
return Array.from(str)
|
||||
.map(char => (validChar(char) ? char : replacement))
|
||||
.map(getCharReplacer(encoding, replacement))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function sanitizeChar(char, options = Map()) {
|
||||
const encoding = options.get('encoding', 'unicode');
|
||||
const replacement = options.get('sanitize_replacement', '-');
|
||||
|
||||
return getCharReplacer(encoding, replacement)(char);
|
||||
}
|
||||
|
||||
export function sanitizeSlug(str, options = Map()) {
|
||||
const encoding = options.get('encoding', 'unicode');
|
||||
const stripDiacritics = options.get('clean_accents', false);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { OrderedMap, fromJS } from 'immutable';
|
||||
import { configLoaded } from 'Actions/config';
|
||||
import collections, { selectAllowDeletion } from '../collections';
|
||||
import collections, { selectAllowDeletion, selectEntryPath, selectEntrySlug } from '../collections';
|
||||
import { FILES, FOLDER } from 'Constants/collectionTypes';
|
||||
|
||||
describe('collections', () => {
|
||||
@ -48,4 +48,32 @@ describe('collections', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectEntryPath', () => {
|
||||
it('should return path', () => {
|
||||
expect(
|
||||
selectEntryPath(
|
||||
fromJS({
|
||||
type: FOLDER,
|
||||
folder: 'posts',
|
||||
}),
|
||||
'dir1/dir2/slug',
|
||||
),
|
||||
).toBe('posts/dir1/dir2/slug.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectEntrySlug', () => {
|
||||
it('should return slug', () => {
|
||||
expect(
|
||||
selectEntrySlug(
|
||||
fromJS({
|
||||
type: FOLDER,
|
||||
folder: 'posts',
|
||||
}),
|
||||
'posts/dir1/dir2/slug.md',
|
||||
),
|
||||
).toBe('dir1/dir2/slug');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -39,15 +39,17 @@ const selectors = {
|
||||
return collection.get('fields');
|
||||
},
|
||||
entryPath(collection, slug) {
|
||||
return `${collection.get('folder').replace(/\/$/, '')}/${slug}.${this.entryExtension(
|
||||
collection,
|
||||
)}`;
|
||||
const folder = collection.get('folder').replace(/\/$/, '');
|
||||
return `${folder}/${slug}.${this.entryExtension(collection)}`;
|
||||
},
|
||||
entrySlug(collection, path) {
|
||||
return path
|
||||
.split('/')
|
||||
const folder = collection.get('folder').replace(/\/$/, '');
|
||||
const slug = path
|
||||
.split(folder + '/')
|
||||
.pop()
|
||||
.replace(new RegExp(`\\.${escapeRegExp(this.entryExtension(collection))}$`), '');
|
||||
|
||||
return slug;
|
||||
},
|
||||
listMethod() {
|
||||
return 'entriesByFolder';
|
||||
|
15
packages/netlify-cms-core/src/selectors/entryDraft.js
Normal file
15
packages/netlify-cms-core/src/selectors/entryDraft.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { slugFormatter } from 'Lib/backendHelper';
|
||||
import { selectEntryPath } from 'Reducers/collections';
|
||||
|
||||
export const selectDraftPath = (state, collection, entry) => {
|
||||
const config = state.config;
|
||||
|
||||
try {
|
||||
// slugFormatter throws in case an identifier is missing from the entry
|
||||
// we can safely ignore this error as this is just a preview path value
|
||||
const slug = slugFormatter(collection, entry.get('data'), config.get('slug'));
|
||||
return selectEntryPath(collection, slug);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
};
|
Reference in New Issue
Block a user