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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -109,7 +109,7 @@ export default class API {
listFiles = async path => {
const { entries, cursor } = await flow([
// sort files by filename ascending
unsentRequest.withParams({ sort: '-path' }),
unsentRequest.withParams({ sort: '-path', max_depth: 10 }),
this.requestJSON,
then(this.getEntriesAndCursor),
])(`${this.repoURL}/src/${this.branch}/${path}`);

View File

@ -155,6 +155,14 @@ export default class API {
return `${this.repo}/${collectionName}/${slug}`;
}
slugFromContentKey(contentKey, collectionName) {
if (!this.useOpenAuthoring) {
return contentKey.substring(collectionName.length + 1);
}
return contentKey.substring(this.repo.length + collectionName.length + 2);
}
generateBranchName(contentKey) {
return `${CMS_BRANCH_PREFIX}/${contentKey}`;
}
@ -365,7 +373,9 @@ export default class API {
listFiles(path, { repoURL = this.repoURL, branch = this.branch } = {}) {
const folderPath = path.replace(/\/$/, '');
return this.request(`${repoURL}/git/trees/${branch}:${folderPath}`).then(res =>
return this.request(`${repoURL}/git/trees/${branch}:${folderPath}`, {
params: { recursive: 10 },
}).then(res =>
res.tree
.filter(file => file.type === 'blob')
.map(file => ({
@ -448,8 +458,9 @@ export default class API {
const { state: currentState, merged_at: mergedAt } = originPRInfo;
if (currentState === 'closed' && mergedAt) {
// The PR has been merged; delete the unpublished entry
const [, collectionName, slug] = contentKey.split('/');
this.deleteUnpublishedEntry(collectionName, slug);
const { collection } = metadata;
const slug = this.slugFromContentKey(contentKey, collection);
this.deleteUnpublishedEntry(collection, slug);
return;
} else if (currentState === 'closed' && !mergedAt) {
if (status !== 'draft') {

View File

@ -186,6 +186,26 @@ export default class GraphQLAPI extends API {
}
}
getAllFiles(entries, path) {
const allFiles = entries.reduce((acc, item) => {
if (item.type === 'tree') {
return [...acc, ...this.getAllFiles(item.object.entries, `${path}/${item.name}`)];
} else if (item.type === 'blob') {
return [
...acc,
{
...item,
path: `${path}/${item.name}`,
size: item.blob && item.blob.size,
},
];
}
return acc;
}, []);
return allFiles;
}
async listFiles(path, { repoURL = this.repoURL, branch = this.branch } = {}) {
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
const { data } = await this.query({
@ -194,14 +214,8 @@ export default class GraphQLAPI extends API {
});
if (data.repository.object) {
const files = data.repository.object.entries
.filter(({ type }) => type === 'blob')
.map(e => ({
...e,
path: `${path}/${e.name}`,
size: e.blob && e.blob.size,
}));
return files;
const allFiles = this.getAllFiles(data.repository.object.entries, path);
return allFiles;
} else {
throw new APIError('Not Found', 404, 'GitHub');
}

View File

@ -0,0 +1,72 @@
import GraphQLAPI from '../GraphQLAPI';
global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests'));
describe('github GraphQL API', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('editorialWorkflowGit', () => {
it('should should flatten nested tree into a list of files', () => {
const api = new GraphQLAPI({ branch: 'gh-pages', repo: 'my-repo' });
const entries = [
{
name: 'post-1.md',
sha: 'sha-1',
type: 'blob',
blob: { size: 1 },
},
{
name: 'post-2.md',
sha: 'sha-2',
type: 'blob',
blob: { size: 2 },
},
{
name: '2019',
sha: 'dir-sha',
type: 'tree',
object: {
entries: [
{
name: 'nested-post.md',
sha: 'nested-post-sha',
type: 'blob',
blob: { size: 3 },
},
],
},
},
];
const path = 'posts';
expect(api.getAllFiles(entries, path)).toEqual([
{
name: 'post-1.md',
sha: 'sha-1',
type: 'blob',
size: 1,
path: 'posts/post-1.md',
blob: { size: 1 },
},
{
name: 'post-2.md',
sha: 'sha-2',
type: 'blob',
size: 2,
path: 'posts/post-2.md',
blob: { size: 2 },
},
{
name: 'nested-post.md',
sha: 'nested-post-sha',
type: 'blob',
size: 3,
path: 'posts/2019/nested-post.md',
blob: { size: 3 },
},
]);
});
});
});

View File

@ -389,7 +389,6 @@ export default class GitHub {
promises.push(
new Promise(resolve => {
const contentKey = this.api.contentKeyFromRef(ref);
const slug = contentKey.split('/').pop();
return sem.take(() =>
this.api
.readUnpublishedBranchFile(contentKey)
@ -399,7 +398,7 @@ export default class GitHub {
sem.leave();
} else {
resolve({
slug,
slug: this.api.slugFromContentKey(contentKey, data.metaData.collection),
file: { path: data.metaData.objects.entry.path },
data: data.fileData,
metaData: data.metaData,

View File

@ -1,4 +1,5 @@
import gql from 'graphql-tag';
import { oneLine } from 'common-tags';
import * as fragments from './fragments';
export const repoPermission = gql`
@ -92,17 +93,47 @@ export const statues = gql`
${fragments.object}
`;
const buildFilesQuery = (depth = 10) => {
const PLACE_HOLDER = 'PLACE_HOLDER';
let query = oneLine`
...ObjectParts
... on Tree {
entries {
...FileEntryParts
${PLACE_HOLDER}
}
}
`;
for (let i = 0; i < depth - 1; i++) {
query = query.replace(
PLACE_HOLDER,
oneLine`
object {
... on Tree {
entries {
...FileEntryParts
${PLACE_HOLDER}
}
}
}
`,
);
}
query = query.replace(PLACE_HOLDER, '');
return query;
};
const filesQuery = buildFilesQuery();
export const files = gql`
query files($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) {
...RepositoryParts
object(expression: $expression) {
...ObjectParts
... on Tree {
entries {
...FileEntryParts
}
}
${filesQuery}
}
}
}

View File

@ -190,7 +190,7 @@ export default class API {
listFiles = async path => {
const firstPageCursor = await this.fetchCursor({
url: `${this.repoURL}/repository/tree`,
params: { path, ref: this.branch },
params: { path, ref: this.branch, recursive: true },
});
const lastPageLink = firstPageCursor.data.getIn(['links', 'last']);
const { entries, cursor } = await this.fetchCursorAndEntries(lastPageLink);

View File

@ -0,0 +1,160 @@
import TestBackend from '../implementation';
describe('test backend implementation', () => {
beforeEach(() => {
jest.resetModules();
});
describe('getEntry', () => {
it('should get entry by path', async () => {
window.repoFiles = {
posts: {
'some-post.md': {
content: 'post content',
},
},
};
const backend = new TestBackend();
await expect(backend.getEntry(null, null, 'posts/some-post.md')).resolves.toEqual({
file: { path: 'posts/some-post.md' },
data: 'post content',
});
});
it('should get entry by nested path', async () => {
window.repoFiles = {
posts: {
dir1: {
dir2: {
'some-post.md': {
content: 'post content',
},
},
},
},
};
const backend = new TestBackend();
await expect(backend.getEntry(null, null, 'posts/dir1/dir2/some-post.md')).resolves.toEqual({
file: { path: 'posts/dir1/dir2/some-post.md' },
data: 'post content',
});
});
});
describe('persistEntry', () => {
it('should persist entry', async () => {
window.repoFiles = {};
const backend = new TestBackend();
const entry = { path: 'posts/some-post.md', raw: 'content', slug: 'some-post.md' };
await backend.persistEntry(entry, [], { newEntry: true });
expect(window.repoFiles).toEqual({
posts: {
'some-post.md': {
content: 'content',
},
},
});
});
it('should persist entry and keep existing unrelated entries', async () => {
window.repoFiles = {
pages: {
'other-page.md': {
content: 'content',
},
},
posts: {
'other-post.md': {
content: 'content',
},
},
};
const backend = new TestBackend();
const entry = { path: 'posts/new-post.md', raw: 'content', slug: 'new-post.md' };
await backend.persistEntry(entry, [], { newEntry: true });
expect(window.repoFiles).toEqual({
pages: {
'other-page.md': {
content: 'content',
},
},
posts: {
'new-post.md': {
content: 'content',
},
'other-post.md': {
content: 'content',
},
},
});
});
it('should persist nested entry', async () => {
window.repoFiles = {};
const backend = new TestBackend();
const slug = encodeURIComponent('dir1/dir2/some-post.md');
const path = `posts/${decodeURIComponent(slug)}`;
const entry = { path, raw: 'content', slug };
await backend.persistEntry(entry, [], { newEntry: true });
expect(window.repoFiles).toEqual({
posts: {
dir1: {
dir2: {
'some-post.md': {
content: 'content',
},
},
},
},
});
});
it('should update existing nested entry', async () => {
window.repoFiles = {
posts: {
dir1: {
dir2: {
'some-post.md': {
mediaFiles: ['file1'],
content: 'content',
},
},
},
},
};
const backend = new TestBackend();
const slug = encodeURIComponent('dir1/dir2/some-post.md');
const path = `posts/${decodeURIComponent(slug)}`;
const entry = { path, raw: 'new content', slug };
await backend.persistEntry(entry, [], { newEntry: false });
expect(window.repoFiles).toEqual({
posts: {
dir1: {
dir2: {
'some-post.md': {
mediaFiles: ['file1'],
content: 'new content',
},
},
},
},
});
});
});
});

View File

@ -188,15 +188,18 @@ export default class TestBackend {
}
const newEntry = options.newEntry || false;
const folder = path.substring(0, path.lastIndexOf('/'));
const fileName = path.substring(path.lastIndexOf('/') + 1);
window.repoFiles[folder] = window.repoFiles[folder] || {};
window.repoFiles[folder][fileName] = window.repoFiles[folder][fileName] || {};
if (newEntry) {
window.repoFiles[folder][fileName] = { content: raw };
} else {
window.repoFiles[folder][fileName].content = raw;
const segments = path.split('/');
const entry = newEntry ? { content: raw } : { ...getFile(path), content: raw };
let obj = window.repoFiles;
while (segments.length > 1) {
const segment = segments.shift();
obj[segment] = obj[segment] || {};
obj = obj[segment];
}
obj[segments.shift()] = entry;
await Promise.all(mediaFiles.map(file => this.persistMedia(file)));
return Promise.resolve();
}

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