mirror of
https://github.com/TryGhost/Ghost-Admin.git
synced 2023-12-14 02:33:04 +01:00
✨ Added ability to override the canonical URL of posts/pages
closes https://github.com/TryGhost/Ghost/issues/10593 - adds a "Canonical URL" field to the Meta Data section of the Post Settings Menu - adds validation for canonical url being a valid absolute or relative URL
This commit is contained in:
parent
76d3b5282f
commit
69571b171f
5 changed files with 178 additions and 72 deletions
|
@ -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;
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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"}}
|
||||
<label for="canonicalUrl">Canonical URL</label>
|
||||
{{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}}
|
||||
|
||||
<div class="form-group">
|
||||
<label>Search Engine Result Preview</label>
|
||||
<div class="seo-preview">
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
65
tests/unit/validators/post-test.js
Normal file
65
tests/unit/validators/post-test.js
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue