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 => { listFiles = async path => {
const { entries, cursor } = await flow([ const { entries, cursor } = await flow([
// sort files by filename ascending // sort files by filename ascending
unsentRequest.withParams({ sort: '-path' }), unsentRequest.withParams({ sort: '-path', max_depth: 10 }),
this.requestJSON, this.requestJSON,
then(this.getEntriesAndCursor), then(this.getEntriesAndCursor),
])(`${this.repoURL}/src/${this.branch}/${path}`); ])(`${this.repoURL}/src/${this.branch}/${path}`);

View File

@ -155,6 +155,14 @@ export default class API {
return `${this.repo}/${collectionName}/${slug}`; 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) { generateBranchName(contentKey) {
return `${CMS_BRANCH_PREFIX}/${contentKey}`; return `${CMS_BRANCH_PREFIX}/${contentKey}`;
} }
@ -365,7 +373,9 @@ export default class API {
listFiles(path, { repoURL = this.repoURL, branch = this.branch } = {}) { listFiles(path, { repoURL = this.repoURL, branch = this.branch } = {}) {
const folderPath = path.replace(/\/$/, ''); 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 res.tree
.filter(file => file.type === 'blob') .filter(file => file.type === 'blob')
.map(file => ({ .map(file => ({
@ -448,8 +458,9 @@ export default class API {
const { state: currentState, merged_at: mergedAt } = originPRInfo; const { state: currentState, merged_at: mergedAt } = originPRInfo;
if (currentState === 'closed' && mergedAt) { if (currentState === 'closed' && mergedAt) {
// The PR has been merged; delete the unpublished entry // The PR has been merged; delete the unpublished entry
const [, collectionName, slug] = contentKey.split('/'); const { collection } = metadata;
this.deleteUnpublishedEntry(collectionName, slug); const slug = this.slugFromContentKey(contentKey, collection);
this.deleteUnpublishedEntry(collection, slug);
return; return;
} else if (currentState === 'closed' && !mergedAt) { } else if (currentState === 'closed' && !mergedAt) {
if (status !== 'draft') { 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 } = {}) { async listFiles(path, { repoURL = this.repoURL, branch = this.branch } = {}) {
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL); const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
const { data } = await this.query({ const { data } = await this.query({
@ -194,14 +214,8 @@ export default class GraphQLAPI extends API {
}); });
if (data.repository.object) { if (data.repository.object) {
const files = data.repository.object.entries const allFiles = this.getAllFiles(data.repository.object.entries, path);
.filter(({ type }) => type === 'blob') return allFiles;
.map(e => ({
...e,
path: `${path}/${e.name}`,
size: e.blob && e.blob.size,
}));
return files;
} else { } else {
throw new APIError('Not Found', 404, 'GitHub'); 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( promises.push(
new Promise(resolve => { new Promise(resolve => {
const contentKey = this.api.contentKeyFromRef(ref); const contentKey = this.api.contentKeyFromRef(ref);
const slug = contentKey.split('/').pop();
return sem.take(() => return sem.take(() =>
this.api this.api
.readUnpublishedBranchFile(contentKey) .readUnpublishedBranchFile(contentKey)
@ -399,7 +398,7 @@ export default class GitHub {
sem.leave(); sem.leave();
} else { } else {
resolve({ resolve({
slug, slug: this.api.slugFromContentKey(contentKey, data.metaData.collection),
file: { path: data.metaData.objects.entry.path }, file: { path: data.metaData.objects.entry.path },
data: data.fileData, data: data.fileData,
metaData: data.metaData, metaData: data.metaData,

View File

@ -1,4 +1,5 @@
import gql from 'graphql-tag'; import gql from 'graphql-tag';
import { oneLine } from 'common-tags';
import * as fragments from './fragments'; import * as fragments from './fragments';
export const repoPermission = gql` export const repoPermission = gql`
@ -92,17 +93,47 @@ export const statues = gql`
${fragments.object} ${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` export const files = gql`
query files($owner: String!, $name: String!, $expression: String!) { query files($owner: String!, $name: String!, $expression: String!) {
repository(owner: $owner, name: $name) { repository(owner: $owner, name: $name) {
...RepositoryParts ...RepositoryParts
object(expression: $expression) { object(expression: $expression) {
...ObjectParts ${filesQuery}
... on Tree {
entries {
...FileEntryParts
}
}
} }
} }
} }

View File

@ -190,7 +190,7 @@ export default class API {
listFiles = async path => { listFiles = async path => {
const firstPageCursor = await this.fetchCursor({ const firstPageCursor = await this.fetchCursor({
url: `${this.repoURL}/repository/tree`, 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 lastPageLink = firstPageCursor.data.getIn(['links', 'last']);
const { entries, cursor } = await this.fetchCursorAndEntries(lastPageLink); 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 newEntry = options.newEntry || false;
const folder = path.substring(0, path.lastIndexOf('/'));
const fileName = path.substring(path.lastIndexOf('/') + 1); const segments = path.split('/');
window.repoFiles[folder] = window.repoFiles[folder] || {}; const entry = newEntry ? { content: raw } : { ...getFile(path), content: raw };
window.repoFiles[folder][fileName] = window.repoFiles[folder][fileName] || {};
if (newEntry) { let obj = window.repoFiles;
window.repoFiles[folder][fileName] = { content: raw }; while (segments.length > 1) {
} else { const segment = segments.shift();
window.repoFiles[folder][fileName].content = raw; obj[segment] = obj[segment] || {};
obj = obj[segment];
} }
obj[segments.shift()] = entry;
await Promise.all(mediaFiles.map(file => this.persistMedia(file))); await Promise.all(mediaFiles.map(file => this.persistMedia(file)));
return Promise.resolve(); return Promise.resolve();
} }

View File

@ -1,6 +1,7 @@
import { resolveBackend, Backend } from '../backend'; import { resolveBackend, Backend } from '../backend';
import registry from 'Lib/registry'; 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('Lib/registry');
jest.mock('netlify-cms-lib-util'); 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, selectAllowNewEntries,
selectAllowDeletion, selectAllowDeletion,
selectFolderEntryExtension, selectFolderEntryExtension,
selectIdentifier,
selectInferedField, selectInferedField,
} from 'Reducers/collections'; } from 'Reducers/collections';
import { createEntry } from 'ValueObjects/Entry'; import { createEntry } from 'ValueObjects/Entry';
import { sanitizeSlug } from 'Lib/urlHelper'; import { sanitizeSlug, sanitizeChar } from 'Lib/urlHelper';
import { getBackend } from 'Lib/registry'; import { getBackend } from 'Lib/registry';
import { commitMessageFormatter } from 'Lib/backendHelper'; import { commitMessageFormatter, slugFormatter, prepareSlug } from 'Lib/backendHelper';
import { import {
localForage, localForage,
Cursor, 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) { function getEntryBackupKey(collectionName, slug) {
const baseKey = 'backup'; const baseKey = 'backup';
if (!collectionName) { if (!collectionName) {
@ -74,28 +58,6 @@ function getEntryBackupKey(collectionName, slug) {
return `${baseKey}.${collectionName}${suffix}`; 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 => const extractSearchFields = searchFields => entry =>
searchFields.reduce((acc, field) => { searchFields.reduce((acc, field) => {
let nestedFields = field.split('.'); let nestedFields = field.split('.');
@ -259,17 +221,15 @@ export class Backend {
async generateUniqueSlug(collection, entryData, slugConfig, usedSlugs) { async generateUniqueSlug(collection, entryData, slugConfig, usedSlugs) {
const slug = slugFormatter(collection, entryData, slugConfig); const slug = slugFormatter(collection, entryData, slugConfig);
const sanitizeEntrySlug = partialRight(sanitizeSlug, slugConfig);
let i = 1; let i = 1;
let sanitizedSlug = slug; let uniqueSlug = slug;
let uniqueSlug = sanitizedSlug;
// Check for duplicate slug in loaded entities store first before repo // Check for duplicate slug in loaded entities store first before repo
while ( while (
usedSlugs.includes(uniqueSlug) || usedSlugs.includes(uniqueSlug) ||
(await this.entryExist(collection, selectEntryPath(collection, uniqueSlug), uniqueSlug)) (await this.entryExist(collection, selectEntryPath(collection, uniqueSlug), uniqueSlug))
) { ) {
uniqueSlug = sanitizeEntrySlug(`${sanitizedSlug} ${i++}`); uniqueSlug = `${slug}${sanitizeChar(' ', slugConfig)}${i++}`;
} }
return uniqueSlug; return uniqueSlug;
} }

View File

@ -94,7 +94,8 @@ const EntryCard = ({
const label = entry.get('label'); const label = entry.get('label');
const entryData = entry.get('data'); const entryData = entry.get('data');
const defaultTitle = label || entryData.get(inferedFields.titleField); 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 summary = collection.get('summary');
const date = parseDateFromEntry(entry, collection) || null; const date = parseDateFromEntry(entry, collection) || null;
const identifier = entryData.get(selectIdentifier(collection)); 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 navigateToCollection = collectionName => navigateCollection(collectionName);
const navigateToNewEntry = collectionName => navigateCollection(`${collectionName}/new`); const navigateToNewEntry = collectionName => navigateCollection(`${collectionName}/new`);
const navigateToEntry = (collectionName, slug) => const navigateToEntry = (collectionName, slug) =>
navigateCollection(`${collectionName}/entries/${slug}`); navigateCollection(`${collectionName}/entries/${encodeURIComponent(slug)}`);
export class Editor extends React.Component { export class Editor extends React.Component {
static propTypes = { static propTypes = {
@ -435,7 +435,7 @@ export class Editor extends React.Component {
function mapStateToProps(state, ownProps) { function mapStateToProps(state, ownProps) {
const { collections, entryDraft, auth, config, entries, globalUI } = state; 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 collection = collections.get(ownProps.match.params.name);
const collectionName = collection.get('name'); const collectionName = collection.get('name');
const newEntry = ownProps.newRecord === true; 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), showDelete: !ownProps.newEntry && selectAllowDeletion(collection),
}; };
if (isEditorialWorkflow) { 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); const unpublishedEntry = selectUnpublishedEntry(state, collection.get('name'), slug);
if (unpublishedEntry) { if (unpublishedEntry) {
returnObj.unpublishedEntry = true; returnObj.unpublishedEntry = true;

View File

@ -204,11 +204,11 @@ class WorkflowList extends React.Component {
<div> <div>
{entries.map(entry => { {entries.map(entry => {
const timestamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('MMMM D'); const timestamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('MMMM D');
const slug = entry.get('slug');
const editLink = `collections/${entry.getIn([ const editLink = `collections/${entry.getIn([
'metaData', 'metaData',
'collection', 'collection',
])}/entries/${entry.get('slug')}`; ])}/entries/${encodeURIComponent(slug)}`;
const slug = entry.get('slug');
const ownStatus = entry.getIn(['metaData', 'status']); const ownStatus = entry.getIn(['metaData', 'status']);
const collection = entry.getIn(['metaData', 'collection']); const collection = entry.getIn(['metaData', 'collection']);
const isModification = entry.get('isModification'); const isModification = entry.get('isModification');

View File

@ -101,6 +101,7 @@ const getConfigSchema = () => ({
identifier_field: { type: 'string' }, identifier_field: { type: 'string' },
summary: { type: 'string' }, summary: { type: 'string' },
slug: { type: 'string' }, slug: { type: 'string' },
path: { type: 'string' },
preview_path: { type: 'string' }, preview_path: { type: 'string' },
preview_path_date_field: { type: 'string' }, preview_path_date_field: { type: 'string' },
create: { type: 'boolean' }, create: { type: 'boolean' },

View File

@ -1,105 +1,162 @@
import { Map } from 'immutable'; import { Map } from 'immutable';
import { commitMessageFormatter } from '../backendHelper'; import { commitMessageFormatter, prepareSlug, slugFormatter } from '../backendHelper';
jest.spyOn(console, 'warn').mockImplementation(() => {}); jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.mock('Reducers/collections');
describe('commitMessageFormatter', () => { describe('backendHelper', () => {
const config = { describe('commitMessageFormatter', () => {
getIn: jest.fn(), const config = {
}; getIn: jest.fn(),
};
const collection = { const collection = {
get: jest.fn().mockReturnValue('Collection'), get: jest.fn().mockReturnValue('Collection'),
}; };
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('should return default commit message on create', () => { it('should return default commit message on create', () => {
expect( expect(
commitMessageFormatter('create', config, { slug: 'doc-slug', path: 'file-path', collection }), commitMessageFormatter('create', config, {
).toEqual('Create Collection “doc-slug'); slug: 'doc-slug',
}); path: 'file-path',
collection,
}),
).toEqual('Create Collection “doc-slug”');
});
it('should return default commit message on create', () => { it('should return default commit message on create', () => {
collection.get.mockReturnValueOnce(undefined); collection.get.mockReturnValueOnce(undefined);
collection.get.mockReturnValueOnce('Collections'); collection.get.mockReturnValueOnce('Collections');
expect( expect(
commitMessageFormatter('update', config, { slug: 'doc-slug', path: 'file-path', collection }), commitMessageFormatter('update', config, {
).toEqual('Update Collections “doc-slug'); slug: 'doc-slug',
}); path: 'file-path',
collection,
}),
).toEqual('Update Collections “doc-slug”');
});
it('should return default commit message on delete', () => { it('should return default commit message on delete', () => {
expect( expect(
commitMessageFormatter('delete', config, { slug: 'doc-slug', path: 'file-path', collection }), commitMessageFormatter('delete', config, {
).toEqual('Delete Collection “doc-slug'); slug: 'doc-slug',
}); path: 'file-path',
collection,
}),
).toEqual('Delete Collection “doc-slug”');
});
it('should return default commit message on uploadMedia', () => { it('should return default commit message on uploadMedia', () => {
expect( expect(
commitMessageFormatter('uploadMedia', config, { commitMessageFormatter('uploadMedia', config, {
slug: 'doc-slug', slug: 'doc-slug',
path: 'file-path', path: 'file-path',
collection, collection,
}), }),
).toEqual('Upload “file-path”'); ).toEqual('Upload “file-path”');
}); });
it('should return default commit message on deleteMedia', () => { it('should return default commit message on deleteMedia', () => {
expect( expect(
commitMessageFormatter('deleteMedia', config, { commitMessageFormatter('deleteMedia', config, {
slug: 'doc-slug', slug: 'doc-slug',
path: 'file-path', path: 'file-path',
collection, collection,
}), }),
).toEqual('Delete “file-path”'); ).toEqual('Delete “file-path”');
}); });
it('should log warning on unknown variable', () => { it('should log warning on unknown variable', () => {
config.getIn.mockReturnValueOnce( config.getIn.mockReturnValueOnce(
Map({ Map({
create: 'Create {{collection}} “{{slug}}” with "{{unknown variable}}"', create: 'Create {{collection}} “{{slug}}” with "{{unknown variable}}"',
}), }),
); );
expect( expect(
commitMessageFormatter('create', config, { commitMessageFormatter('create', config, {
slug: 'doc-slug', slug: 'doc-slug',
path: 'file-path', path: 'file-path',
collection, collection,
}), }),
).toEqual('Create Collection “doc-slug” with ""'); ).toEqual('Create Collection “doc-slug” with ""');
expect(console.warn).toHaveBeenCalledTimes(1); expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith( expect(console.warn).toHaveBeenCalledWith(
'Ignoring unknown variable “unknown variable” in commit message template.', 'Ignoring unknown variable “unknown variable” in commit message template.',
); );
}); });
it('should return custom commit message on update', () => { it('should return custom commit message on update', () => {
config.getIn.mockReturnValueOnce( config.getIn.mockReturnValueOnce(
Map({ Map({
update: 'Custom commit message', update: 'Custom commit message',
}), }),
); );
expect( expect(
commitMessageFormatter('update', config, { commitMessageFormatter('update', config, {
slug: 'doc-slug', slug: 'doc-slug',
path: 'file-path', path: 'file-path',
collection, collection,
}), }),
).toEqual('Custom commit message'); ).toEqual('Custom commit message');
}); });
it('should return custom open authoring message', () => { it('should return custom open authoring message', () => {
config.getIn.mockReturnValueOnce( config.getIn.mockReturnValueOnce(
Map({ Map({
openAuthoring: '{{author-login}} - {{author-name}}: {{message}}', 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( commitMessageFormatter(
'create', 'create',
config, config,
@ -111,54 +168,101 @@ describe('commitMessageFormatter', () => {
authorName: 'Test User', authorName: 'Test User',
}, },
true, 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', () => { describe('prepareSlug', () => {
config.getIn.mockReturnValueOnce( it('should trim slug', () => {
Map({ expect(prepareSlug(' slug ')).toBe('slug');
openAuthoring: '{{author-login}} - {{author-name}}: {{message}}', });
}),
);
expect( it('should lowercase slug', () => {
commitMessageFormatter( expect(prepareSlug('Slug')).toBe('slug');
'create', });
config,
{ it('should remove single quotes', () => {
slug: 'doc-slug', expect(prepareSlug(`sl'ug`)).toBe('slug');
path: 'file-path', });
collection,
}, it('should replace periods with slashes', () => {
true, expect(prepareSlug(`sl.ug`)).toBe('sl-ug');
), });
).toEqual(' - : Create Collection “doc-slug”');
}); });
it('should log warning on unknown variable in open authoring template', () => { describe('slugFormatter', () => {
config.getIn.mockReturnValueOnce( const date = new Date('2020-01-01');
Map({ jest.spyOn(global, 'Date').mockImplementation(() => date);
openAuthoring: '{{author-email}}: {{message}}',
}),
);
commitMessageFormatter( const { selectIdentifier } = require('Reducers/collections');
'create',
config,
{
slug: 'doc-slug',
path: 'file-path',
collection,
authorLogin: 'user-login',
authorName: 'Test User',
},
true,
);
expect(console.warn).toHaveBeenCalledTimes(1); beforeEach(() => {
expect(console.warn).toHaveBeenCalledWith( jest.clearAllMocks();
'Ignoring unknown variable “author-email” in open authoring message template.', });
);
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 { Map } from 'immutable';
import { sanitizeURI, sanitizeSlug } from '../urlHelper'; import { sanitizeURI, sanitizeSlug, sanitizeChar } from '../urlHelper';
describe('sanitizeURI', () => { describe('sanitizeURI', () => {
// `sanitizeURI` tests from RFC 3987 // `sanitizeURI` tests from RFC 3987
@ -113,3 +113,13 @@ describe('sanitizeSlug', () => {
expect(sanitizeSlug('test test ', Map({ sanitize_replacement: '_' }))).toEqual('test_test'); 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 { 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({ const commitMessageTemplates = Map({
create: 'Create {{collection}} “{{slug}}”', create: 'Create {{collection}} “{{slug}}”',
@ -55,3 +59,47 @@ export const commitMessageFormatter = (
return message; 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 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 validURIChar = char => uriChars.test(char);
const validIRIChar = char => uriChars.test(char) || ucsChars.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; let validChar;
if (encoding === 'unicode') { if (encoding === 'unicode') {
validChar = validIRIChar; validChar = validIRIChar;
} else if (encoding === 'ascii') { } 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.'); 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 // `Array.from` must be used instead of `String.split` because
// `split` converts things like emojis into UTF-16 surrogate pairs. // `split` converts things like emojis into UTF-16 surrogate pairs.
return Array.from(str) return Array.from(str)
.map(char => (validChar(char) ? char : replacement)) .map(getCharReplacer(encoding, replacement))
.join(''); .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()) { export function sanitizeSlug(str, options = Map()) {
const encoding = options.get('encoding', 'unicode'); const encoding = options.get('encoding', 'unicode');
const stripDiacritics = options.get('clean_accents', false); const stripDiacritics = options.get('clean_accents', false);

View File

@ -1,6 +1,6 @@
import { OrderedMap, fromJS } from 'immutable'; import { OrderedMap, fromJS } from 'immutable';
import { configLoaded } from 'Actions/config'; import { configLoaded } from 'Actions/config';
import collections, { selectAllowDeletion } from '../collections'; import collections, { selectAllowDeletion, selectEntryPath, selectEntrySlug } from '../collections';
import { FILES, FOLDER } from 'Constants/collectionTypes'; import { FILES, FOLDER } from 'Constants/collectionTypes';
describe('collections', () => { describe('collections', () => {
@ -48,4 +48,32 @@ describe('collections', () => {
).toBe(false); ).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'); return collection.get('fields');
}, },
entryPath(collection, slug) { entryPath(collection, slug) {
return `${collection.get('folder').replace(/\/$/, '')}/${slug}.${this.entryExtension( const folder = collection.get('folder').replace(/\/$/, '');
collection, return `${folder}/${slug}.${this.entryExtension(collection)}`;
)}`;
}, },
entrySlug(collection, path) { entrySlug(collection, path) {
return path const folder = collection.get('folder').replace(/\/$/, '');
.split('/') const slug = path
.split(folder + '/')
.pop() .pop()
.replace(new RegExp(`\\.${escapeRegExp(this.entryExtension(collection))}$`), ''); .replace(new RegExp(`\\.${escapeRegExp(this.entryExtension(collection))}$`), '');
return slug;
}, },
listMethod() { listMethod() {
return 'entriesByFolder'; 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 '';
}
};