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