feat: content in sub folders (#2897)

This commit is contained in:
Erez Rokah
2019-11-28 05:39:33 +02:00
committed by Shawn Erquhart
parent 766ade1a3c
commit afcfe5b6d5
44 changed files with 7218 additions and 8225 deletions

View File

@ -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',
);
});
});
});

View File

@ -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;
}

View File

@ -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));

View File

@ -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;

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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>
`;

View File

@ -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;

View File

@ -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');

View File

@ -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' },

View File

@ -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');
});
});
});

View File

@ -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('_');
});
});

View File

@ -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),
);
}
};

View File

@ -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);

View File

@ -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');
});
});
});

View File

@ -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';

View 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 '';
}
};