Skip to content
This repository has been archived by the owner on Nov 28, 2022. It is now read-only.

Commit

Permalink
✨ Added ability to override the canonical URL of posts/pages
Browse files Browse the repository at this point in the history
closes TryGhost/Ghost#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
  • Loading branch information
kevinansfield committed Mar 12, 2019
1 parent 76d3b52 commit 69571b1
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 72 deletions.
50 changes: 42 additions & 8 deletions app/components/gh-post-settings-menu.js
Expand Up @@ -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'),
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions app/models/post.js
Expand Up @@ -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'),
Expand Down Expand Up @@ -133,6 +134,7 @@ export default Model.extend(Comparable, ValidationEngine, {
publishedAtBlogDate: '',
publishedAtBlogTime: '',

canonicalUrlScratch: boundOneWay('canonicalUrl'),
customExcerptScratch: boundOneWay('customExcerpt'),
codeinjectionFootScratch: boundOneWay('codeinjectionFoot'),
codeinjectionHeadScratch: boundOneWay('codeinjectionHead'),
Expand Down
13 changes: 13 additions & 0 deletions app/templates/components/gh-post-settings-menu.hbs
Expand Up @@ -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">
Expand Down
120 changes: 56 additions & 64 deletions app/validators/post.js
Expand Up @@ -8,6 +8,7 @@ export default BaseValidator.create({
'title',
'authors',
'customExcerpt',
'canonicalUrl',
'codeinjectionHead',
'codeinjectionFoot',
'metaTitle',
Expand All @@ -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();
}
},

canonicalUrl(model) {
let validatorOptions = {require_protocol: true};
let urlRegex = new RegExp(/^(\/|[a-zA-Z0-9-]+:)/);
let url = model.canonicalUrl;

if (isEmpty(authors)) {
model.get('errors').add('authors', 'At least one author is required.');
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
Expand All @@ -142,47 +135,46 @@ 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;
}

// 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();
}
}
Expand Down

0 comments on commit 69571b1

Please sign in to comment.