diff --git a/ghost/admin/app/components/gh-post-settings-menu.js b/ghost/admin/app/components/gh-post-settings-menu.js
index 01d0bb2133..efa9495398 100644
--- a/ghost/admin/app/components/gh-post-settings-menu.js
+++ b/ghost/admin/app/components/gh-post-settings-menu.js
@@ -26,6 +26,7 @@ export default Component.extend(SettingsMenuMixin, {
_showSettingsMenu: false,
_showThrobbers: false,
+ canonicalUrlScratch: alias('post.canonicalUrlScratch'),
customExcerptScratch: alias('post.customExcerptScratch'),
codeinjectionFootScratch: alias('post.codeinjectionFootScratch'),
codeinjectionHeadScratch: alias('post.codeinjectionHeadScratch'),
@@ -70,17 +71,27 @@ export default Component.extend(SettingsMenuMixin, {
return placeholder;
}),
- seoURL: computed('post.slug', 'config.blogUrl', function () {
+ seoURL: computed('post.{slug,canonicalUrl}', 'config.blogUrl', function () {
let blogUrl = this.get('config.blogUrl');
- let seoSlug = this.get('post.slug') ? this.get('post.slug') : '';
- let seoURL = `${blogUrl}/${seoSlug}`;
+ let seoSlug = this.post.slug || '';
+ let canonicalUrl = this.post.canonicalUrl || '';
- // only append a slash to the URL if the slug exists
- if (seoSlug) {
- seoURL += '/';
+ if (canonicalUrl) {
+ if (canonicalUrl.match(/^\//)) {
+ return `${blogUrl}${canonicalUrl}`;
+ } else {
+ return canonicalUrl;
+ }
+ } else {
+ let seoURL = `${blogUrl}/${seoSlug}`;
+
+ // only append a slash to the URL if the slug exists
+ if (seoSlug) {
+ seoURL += '/';
+ }
+
+ return seoURL;
}
-
- return seoURL;
}),
didReceiveAttrs() {
@@ -276,6 +287,29 @@ export default Component.extend(SettingsMenuMixin, {
});
},
+ setCanonicalUrl(value) {
+ // Grab the post and current stored meta description
+ let post = this.post;
+ let currentCanonicalUrl = post.canonicalUrl;
+
+ // If the value entered matches the stored value, do nothing
+ if (currentCanonicalUrl === value) {
+ return;
+ }
+
+ // If the value supplied is different, set it as the new value
+ post.set('canonicalUrl', value);
+
+ // Make sure the value is valid and if so, save it into the post
+ return post.validate({property: 'canonicalUrl'}).then(() => {
+ if (post.get('isNew')) {
+ return;
+ }
+
+ return this.savePost.perform();
+ });
+ },
+
setOgTitle(ogTitle) {
// Grab the post and current stored facebook title
let post = this.post;
diff --git a/ghost/admin/app/models/post.js b/ghost/admin/app/models/post.js
index e492c97833..72273826d9 100644
--- a/ghost/admin/app/models/post.js
+++ b/ghost/admin/app/models/post.js
@@ -80,6 +80,7 @@ export default Model.extend(Comparable, ValidationEngine, {
customExcerpt: attr('string'),
featured: attr('boolean', {defaultValue: false}),
featureImage: attr('string'),
+ canonicalUrl: attr('string'),
codeinjectionFoot: attr('string', {defaultValue: ''}),
codeinjectionHead: attr('string', {defaultValue: ''}),
customTemplate: attr('string'),
@@ -133,6 +134,7 @@ export default Model.extend(Comparable, ValidationEngine, {
publishedAtBlogDate: '',
publishedAtBlogTime: '',
+ canonicalUrlScratch: boundOneWay('canonicalUrl'),
customExcerptScratch: boundOneWay('customExcerpt'),
codeinjectionFootScratch: boundOneWay('codeinjectionFoot'),
codeinjectionHeadScratch: boundOneWay('codeinjectionHead'),
diff --git a/ghost/admin/app/templates/components/gh-post-settings-menu.hbs b/ghost/admin/app/templates/components/gh-post-settings-menu.hbs
index 0315613020..2c142168eb 100644
--- a/ghost/admin/app/templates/components/gh-post-settings-menu.hbs
+++ b/ghost/admin/app/templates/components/gh-post-settings-menu.hbs
@@ -194,6 +194,19 @@
{{gh-error-message errors=post.errors property="meta-description"}}
{{/gh-form-group}}
+ {{#gh-form-group errors=post.errors hasValidated=post.hasValidated property="canonicalUrl"}}
+
+ {{gh-text-input
+ class="post-setting-canonicalUrl"
+ name="post-setting-canonicalUrl"
+ value=(readonly canonicalUrlScratch)
+ input=(action (mut canonicalUrlScratch) value="target.value")
+ focus-out=(action "setCanonicalUrl" canonicalUrlScratch)
+ stopEnterKeyDownPropagation="true"
+ data-test-field="canonicalUrl"}}
+ {{gh-error-message errors=post.errors property="canonicalUrl"}}
+ {{/gh-form-group}}
+
diff --git a/ghost/admin/app/validators/post.js b/ghost/admin/app/validators/post.js
index ca3d51fc9d..3219855edd 100644
--- a/ghost/admin/app/validators/post.js
+++ b/ghost/admin/app/validators/post.js
@@ -8,6 +8,7 @@ export default BaseValidator.create({
'title',
'authors',
'customExcerpt',
+ 'canonicalUrl',
'codeinjectionHead',
'codeinjectionFoot',
'metaTitle',
@@ -21,118 +22,110 @@ export default BaseValidator.create({
],
title(model) {
- let title = model.get('title');
-
- if (isBlank(title)) {
- model.get('errors').add('title', 'You must specify a title for the post.');
+ if (isBlank(model.title)) {
+ model.errors.add('title', 'You must specify a title for the post.');
this.invalidate();
}
- if (!validator.isLength(title || '', 0, 255)) {
- model.get('errors').add('title', 'Title cannot be longer than 255 characters.');
+ if (!validator.isLength(model.title || '', 0, 255)) {
+ model.errors.add('title', 'Title cannot be longer than 255 characters.');
this.invalidate();
}
},
authors(model) {
- let authors = model.get('authors');
+ if (isEmpty(model.authors)) {
+ model.errors.add('authors', 'At least one author is required.');
+ this.invalidate();
+ }
+ },
- if (isEmpty(authors)) {
- model.get('errors').add('authors', 'At least one author is required.');
+ canonicalUrl(model) {
+ let validatorOptions = {require_protocol: true};
+ let urlRegex = new RegExp(/^(\/|[a-zA-Z0-9-]+:)/);
+ let url = model.canonicalUrl;
+
+ if (isBlank(url)) {
+ return;
+ }
+
+ if (url.match(/\s/) || (!validator.isURL(url, validatorOptions) && !url.match(urlRegex))) {
+ model.errors.add('canonicalUrl', 'Please enter a valid URL');
+ this.invalidate();
+ } else if (!validator.isLength(model.canonicalUrl, 0, 2000)) {
+ model.errors.add('canonicalUrl', 'Canonical URL is too long, max 2000 chars');
this.invalidate();
}
},
customExcerpt(model) {
- let customExcerpt = model.get('customExcerpt');
-
- if (!validator.isLength(customExcerpt || '', 0, 300)) {
- model.get('errors').add('customExcerpt', 'Excerpt cannot be longer than 300 characters.');
+ if (!validator.isLength(model.customExcerpt || '', 0, 300)) {
+ model.errors.add('customExcerpt', 'Excerpt cannot be longer than 300 characters.');
this.invalidate();
}
},
codeinjectionFoot(model) {
- let codeinjectionFoot = model.get('codeinjectionFoot');
-
- if (!validator.isLength(codeinjectionFoot || '', 0, 65535)) {
- model.get('errors').add('codeinjectionFoot', 'Footer code cannot be longer than 65535 characters.');
+ if (!validator.isLength(model.codeinjectionFoot || '', 0, 65535)) {
+ model.errors.add('codeinjectionFoot', 'Footer code cannot be longer than 65535 characters.');
this.invalidate();
}
},
codeinjectionHead(model) {
- let codeinjectionHead = model.get('codeinjectionHead');
-
- if (!validator.isLength(codeinjectionHead || '', 0, 65535)) {
- model.get('errors').add('codeinjectionHead', 'Header code cannot be longer than 65535 characters.');
+ if (!validator.isLength(model.codeinjectionHead || '', 0, 65535)) {
+ model.errors.add('codeinjectionHead', 'Header code cannot be longer than 65535 characters.');
this.invalidate();
}
},
metaTitle(model) {
- let metaTitle = model.get('metaTitle');
-
- if (!validator.isLength(metaTitle || '', 0, 300)) {
- model.get('errors').add('metaTitle', 'Meta Title cannot be longer than 300 characters.');
+ if (!validator.isLength(model.metaTitle || '', 0, 300)) {
+ model.errors.add('metaTitle', 'Meta Title cannot be longer than 300 characters.');
this.invalidate();
}
},
metaDescription(model) {
- let metaDescription = model.get('metaDescription');
-
- if (!validator.isLength(metaDescription || '', 0, 500)) {
- model.get('errors').add('metaDescription', 'Meta Description cannot be longer than 500 characters.');
+ if (!validator.isLength(model.metaDescription || '', 0, 500)) {
+ model.errors.add('metaDescription', 'Meta Description cannot be longer than 500 characters.');
this.invalidate();
}
},
ogTitle(model) {
- let ogTitle = model.get('ogTitle');
-
- if (!validator.isLength(ogTitle || '', 0, 300)) {
- model.get('errors').add('ogTitle', 'Facebook Title cannot be longer than 300 characters.');
+ if (!validator.isLength(model.ogTitle || '', 0, 300)) {
+ model.errors.add('ogTitle', 'Facebook Title cannot be longer than 300 characters.');
this.invalidate();
}
},
ogDescription(model) {
- let ogDescription = model.get('ogDescription');
-
- if (!validator.isLength(ogDescription || '', 0, 500)) {
- model.get('errors').add('ogDescription', 'Facebook Description cannot be longer than 500 characters.');
+ if (!validator.isLength(model.ogDescription || '', 0, 500)) {
+ model.errors.add('ogDescription', 'Facebook Description cannot be longer than 500 characters.');
this.invalidate();
}
},
twitterTitle(model) {
- let twitterTitle = model.get('twitterTitle');
-
- if (!validator.isLength(twitterTitle || '', 0, 300)) {
- model.get('errors').add('twitterTitle', 'Twitter Title cannot be longer than 300 characters.');
+ if (!validator.isLength(model.twitterTitle || '', 0, 300)) {
+ model.errors.add('twitterTitle', 'Twitter Title cannot be longer than 300 characters.');
this.invalidate();
}
},
twitterDescription(model) {
- let twitterDescription = model.get('twitterDescription');
-
- if (!validator.isLength(twitterDescription || '', 0, 500)) {
- model.get('errors').add('twitterDescription', 'Twitter Description cannot be longer than 500 characters.');
+ if (!validator.isLength(model.twitterDescription || '', 0, 500)) {
+ model.errors.add('twitterDescription', 'Twitter Description cannot be longer than 500 characters.');
this.invalidate();
}
},
// for posts which haven't been published before and where the blog date/time
// is blank we should ignore the validation
_shouldValidatePublishedAtBlog(model) {
- let publishedAtUTC = model.get('publishedAtUTC');
- let publishedAtBlogDate = model.get('publishedAtBlogDate');
- let publishedAtBlogTime = model.get('publishedAtBlogTime');
-
- return isPresent(publishedAtUTC)
- || isPresent(publishedAtBlogDate)
- || isPresent(publishedAtBlogTime);
+ return isPresent(model.publishedAtUTC)
+ || isPresent(model.publishedAtBlogDate)
+ || isPresent(model.publishedAtBlogTime);
},
// convenience method as .validate({property: 'x'}) doesn't accept multiple properties
@@ -142,18 +135,17 @@ export default BaseValidator.create({
},
publishedAtBlogTime(model) {
- let publishedAtBlogTime = model.get('publishedAtBlogTime');
let timeRegex = /^(([0-1]?[0-9])|([2][0-3])):([0-5][0-9])$/;
- if (!timeRegex.test(publishedAtBlogTime) && this._shouldValidatePublishedAtBlog(model)) {
- model.get('errors').add('publishedAtBlogTime', 'Must be in format: "15:00"');
+ if (!timeRegex.test(model.publishedAtBlogTime) && this._shouldValidatePublishedAtBlog(model)) {
+ model.errors.add('publishedAtBlogTime', 'Must be in format: "15:00"');
this.invalidate();
}
},
publishedAtBlogDate(model) {
- let publishedAtBlogDate = model.get('publishedAtBlogDate');
- let publishedAtBlogTime = model.get('publishedAtBlogTime');
+ let publishedAtBlogDate = model.publishedAtBlogDate;
+ let publishedAtBlogTime = model.publishedAtBlogTime;
if (!this._shouldValidatePublishedAtBlog(model)) {
return;
@@ -161,28 +153,28 @@ export default BaseValidator.create({
// we have a time string but no date string
if (isBlank(publishedAtBlogDate) && !isBlank(publishedAtBlogTime)) {
- model.get('errors').add('publishedAtBlogDate', 'Can\'t be blank');
+ model.errors.add('publishedAtBlogDate', 'Can\'t be blank');
return this.invalidate();
}
// don't validate the date if the time format is incorrect
- if (isEmpty(model.get('errors').errorsFor('publishedAtBlogTime'))) {
- let status = model.get('statusScratch') || model.get('status');
+ if (isEmpty(model.errors.errorsFor('publishedAtBlogTime'))) {
+ let status = model.statusScratch || model.status;
let now = moment();
- let publishedAtUTC = model.get('publishedAtUTC');
- let publishedAtBlogTZ = model.get('publishedAtBlogTZ');
+ let publishedAtUTC = model.publishedAtUTC;
+ let publishedAtBlogTZ = model.publishedAtBlogTZ;
let matchesExisting = publishedAtUTC && publishedAtBlogTZ.isSame(publishedAtUTC);
let isInFuture = publishedAtBlogTZ.isSameOrAfter(now.add(2, 'minutes'));
// draft/published must be in past
if ((status === 'draft' || status === 'published') && publishedAtBlogTZ.isSameOrAfter(now)) {
- model.get('errors').add('publishedAtBlogDate', 'Must be in the past');
+ model.errors.add('publishedAtBlogDate', 'Must be in the past');
this.invalidate();
// scheduled must be at least 2 mins in the future
// ignore if it matches publishedAtUTC as that is likely an update of a scheduled post
} else if (status === 'scheduled' && !matchesExisting && !isInFuture) {
- model.get('errors').add('publishedAtBlogDate', 'Must be at least 2 mins in the future');
+ model.errors.add('publishedAtBlogDate', 'Must be at least 2 mins in the future');
this.invalidate();
}
}
diff --git a/ghost/admin/tests/unit/validators/post-test.js b/ghost/admin/tests/unit/validators/post-test.js
new file mode 100644
index 0000000000..5a23c55a8f
--- /dev/null
+++ b/ghost/admin/tests/unit/validators/post-test.js
@@ -0,0 +1,65 @@
+import EmberObject from '@ember/object';
+import ValidationEngine from 'ghost-admin/mixins/validation-engine';
+import {
+ describe,
+ it
+} from 'mocha';
+import {expect} from 'chai';
+
+const Post = EmberObject.extend(ValidationEngine, {
+ validationType: 'post',
+
+ email: null
+});
+
+describe('Unit: Validator: post', function () {
+ describe('canonicalUrl', function () {
+ it('can be blank', async function () {
+ let post = Post.create({canonicalUrl: ''});
+ let passed = await post.validate({property: 'canonicalUrl'}).then(() => true);
+
+ expect(passed, 'passed').to.be.true;
+ expect(post.hasValidated).to.include('canonicalUrl');
+ });
+
+ it('can be an absolute URL', async function () {
+ let post = Post.create({canonicalUrl: 'http://example.com'});
+ let passed = await post.validate({property: 'canonicalUrl'}).then(() => true);
+
+ expect(passed, 'passed').to.be.true;
+ expect(post.hasValidated).to.include('canonicalUrl');
+ });
+
+ it('can be a relative URL', async function () {
+ let post = Post.create({canonicalUrl: '/my-other-post'});
+ let passed = await post.validate({property: 'canonicalUrl'}).then(() => true);
+
+ expect(passed, 'passed').to.be.true;
+ expect(post.hasValidated).to.include('canonicalUrl');
+ });
+
+ it('cannot be a random string', async function () {
+ let post = Post.create({canonicalUrl: 'asdfghjk'});
+ let passed = await post.validate({property: 'canonicalUrl'}).then(() => true);
+
+ expect(passed, 'passed').to.be.false;
+ expect(post.hasValidated).to.include('canonicalUrl');
+
+ let error = post.errors.errorsFor('canonicalUrl').get(0);
+ expect(error.attribute).to.equal('canonicalUrl');
+ expect(error.message).to.equal('Please enter a valid URL');
+ });
+
+ it('cannot be too long', async function () {
+ let post = Post.create({canonicalUrl: `http://example.com/${(new Array(1983).join('x'))}`});
+ let passed = await post.validate({property: 'canonicalUrl'}).then(() => true);
+
+ expect(passed, 'passed').to.be.false;
+ expect(post.hasValidated).to.include('canonicalUrl');
+
+ let error = post.errors.errorsFor('canonicalUrl').get(0);
+ expect(error.attribute).to.equal('canonicalUrl');
+ expect(error.message).to.equal('Please enter a valid URL');
+ });
+ });
+});