From 23e98aa8dcad3ae79b0460cfb8abd35575096b0c Mon Sep 17 00:00:00 2001 From: cobbspur Date: Tue, 14 Oct 2014 15:18:42 +0200 Subject: [PATCH] Adds twitter cards and schema.org to {{ghost_head}} closes #3900 - Adds twitter cards to ghost head helper - Adds schema json information - Adds test with null values for post image and cover image - Adds test for privacy flag - Adds test for the case of no tags - Updates test to check for twitter card and schema data - Updates privacy.md - Fixes issue with image urls that are linked by url rather than uploaded --- PRIVACY.md | 9 +- core/server/helpers/ghost_head.js | 88 +++++++--- .../unit/server_helpers/ghost_head_spec.js | 161 +++++++++++++++++- 3 files changed, 229 insertions(+), 29 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 45868e5368..dc3c0953d2 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -2,7 +2,7 @@ This is a plain English summary of all of the components within Ghost which may affect your privacy in some way. Please keep in mind that if you use third party Themes or Apps with Ghost, there may be additional things not listed here. -Each of the items listed in this document can be disabled via the `config.js` file. Please see the the [configuration guide](http://support.ghost.org/config/) in the support documentation for details. +Each of the items listed in this document can be disabled via Ghost's `config.js` file. Check out the [configuration guide](http://support.ghost.org/config/) for details. ## Official Services @@ -42,9 +42,10 @@ RPC pings only happen when Ghost is running in the `production` environment. The default theme which comes with Ghost contains three sharing buttons to [Twitter](http://twitter.com), [Facebook](http://facebook.com), and [Google Plus](http://plus.google.com). No resources are loaded from any services, however the buttons do allow visitors to your blog to share your content publicly on these respective networks. - ### Structured Data -Ghost outputs Meta data for your blog that allows published content to be more easily machine-readable. This allows content to be easily discoverable in search engines as well as popular social networks where blog posts are typically shared. +Ghost outputs basic meta tags to allow rich snippets of your content to be recognised by popular social networks. Currently there are 3 supported rich data protocols which are output in `{{ghost_head}}`: -This includes output for post.hbs in {{ghost_head}} based on the Open Graph protocol specification. \ No newline at end of file +- Schema.org - http://schema.org/docs/documents.html +- Open Graph - http://ogp.me/ +- Twitter cards - https://dev.twitter.com/cards/overview \ No newline at end of file diff --git a/core/server/helpers/ghost_head.js b/core/server/helpers/ghost_head.js index 62e32fda61..c0468f5929 100644 --- a/core/server/helpers/ghost_head.js +++ b/core/server/helpers/ghost_head.js @@ -32,10 +32,11 @@ ghost_head = function (options) { trimmedUrlpattern = /.+(?=\/page\/\d*\/)/, trimmedUrl, next, prev, tags, ops = [], - structuredData; + structuredData, + coverImage, authorImage, keywords, + schema; trimmedVersion = trimmedVersion ? trimmedVersion.match(majorMinor)[0] : '?'; - // Push Async calls to an array of promises ops.push(urlHelper.call(self, {hash: {absolute: true}})); ops.push(meta_description.call(self)); @@ -46,12 +47,16 @@ ghost_head = function (options) { var url = results[0].value(), metaDescription = results[1].value(), metaTitle = results[2].value(), - publishedDate, modifiedDate; + publishedDate, modifiedDate, + tags = tagsHelper.call(self.post, {hash: {autolink: 'false'}}).string.split(','), + card = 'content'; if (!metaDescription) { - metaDescription = excerpt.call(self.post, {hash: {words: '40'}}); + metaDescription = excerpt.call(self.post, {hash: {words: '40'}}).string; + } + if (tags[0] !== '') { + keywords = tagsHelper.call(self.post, {hash: {autolink: 'false', seperator: ', '}}).string; } - head.push(''); if (self.pagination) { @@ -73,33 +78,76 @@ ghost_head = function (options) { publishedDate = moment(self.post.published_at).toISOString(); modifiedDate = moment(self.post.updated_at).toISOString(); + if (self.post.image) { + coverImage = self.post.image; + // Test to see if image was linked by url or uploaded + coverImage = coverImage.substring(0, 4) === 'http' ? coverImage : _.escape(blog.url) + coverImage; + card = 'summary_large_image'; + } + + if (self.post.author.image) { + authorImage = self.post.author.image; + // Test to see if image was linked by url or uploaded + authorImage = authorImage.substring(0, 4) === 'http' ? authorImage : _.escape(blog.url) + authorImage; + } + + schema = { + '@context': 'http://schema.org', + '@type': 'Article', + publisher: _.escape(blog.title), + author: { + '@type': 'Person', + name: self.post.author.name, + image: authorImage, + url: _.escape(blog.url) + '/author/' + self.post.author.slug, + sameAs: self.post.author.website + }, + headline: metaTitle, + url: url, + datePublished: publishedDate, + dateModified: modifiedDate, + image: coverImage, + keywords: keywords, + description: metaDescription + }; + structuredData = { 'og:site_name': _.escape(blog.title), 'og:type': 'article', 'og:title': metaTitle, 'og:description': metaDescription + '...', 'og:url': url, + 'og:image': coverImage, 'article:published_time': publishedDate, - 'article:modified_time': modifiedDate + 'article:modified_time': modifiedDate, + 'article:tag': tags, + 'twitter:card': card, + 'twitter:title': metaTitle, + 'twitter:description': metaDescription + '...', + 'twitter:url': url, + 'twitter:image:src': coverImage }; - - if (self.post.image) { - structuredData['og:image'] = _.escape(blog.url) + self.post.image; - } - + head.push(''); _.each(structuredData, function (content, property) { - head.push(''); - }); - - // Calls tag helper and assigns an array of tag names for a post - tags = tagsHelper.call(self.post, {hash: {autolink: 'false'}}).string.split(','); - - _.each(tags, function (tag) { - if (tag !== '') { - head.push(''); + if (property === 'article:tag') { + _.each(tags, function (tag) { + if (tag !== '') { + head.push(''); + } + }); + head.push(''); + } else if (content !== null && content !== undefined) { + if (property.substring(0, 7) === 'twitter') { + head.push(''); + } else { + head.push(''); + } } }); + head.push(''); + head.push('\n'); } + head.push(''); head.push(''); diff --git a/core/test/unit/server_helpers/ghost_head_spec.js b/core/test/unit/server_helpers/ghost_head_spec.js index a5d1a1bee2..f48f70120a 100644 --- a/core/test/unit/server_helpers/ghost_head_spec.js +++ b/core/test/unit/server_helpers/ghost_head_spec.js @@ -50,19 +50,127 @@ describe('{{ghost_head}} helper', function () { }).catch(done); }); - it('returns open graph data on post page', function (done) { + it('returns structured data on post page with author image and post cover image', function (done) { var post = { meta_description: 'blog description', title: 'Welcome to Ghost', image: '/test-image.png', published_at: moment('2008-05-31T19:18:15').toISOString(), updated_at: moment('2014-10-06T15:23:54').toISOString(), - tags: [{name: 'tag1'}, {name: 'tag2'}, {name: 'tag3'}] + tags: [{name: 'tag1'}, {name: 'tag2'}, {name: 'tag3'}], + author: { + name: 'Author name', + url: 'http//:testauthorurl.com', + slug: 'Author', + image: '/test-author-image.png', + website: 'http://authorwebsite.com' + } }; helpers.ghost_head.call({relativeUrl: '/post/', version: '0.3.0', post: post}).then(function (rendered) { should.exist(rendered); - rendered.string.should.equal('\n' + + rendered.string.should.equal('\n \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n \n' + + ' \n\n' + + ' \n' + + ' '); + + done(); + }).catch(done); + }); + + it('returns structured without tags if there are no tags', function (done) { + var post = { + meta_description: 'blog description', + title: 'Welcome to Ghost', + image: '/test-image.png', + published_at: moment('2008-05-31T19:18:15').toISOString(), + updated_at: moment('2014-10-06T15:23:54').toISOString(), + tags: [], + author: { + name: 'Author name', + url: 'http//:testauthorurl.com', + slug: 'Author', + image: '/test-author-image.png', + website: 'http://authorwebsite.com' + } + }; + + helpers.ghost_head.call({relativeUrl: '/post/', version: '0.3.0', post: post}).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('\n \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n \n' + + ' \n\n' + + ' \n' + + ' '); + + done(); + }).catch(done); + }); + + it('returns structured data on post page without author image and post cover image', function (done) { + var post = { + meta_description: 'blog description', + title: 'Welcome to Ghost', + image: null, + published_at: moment('2008-05-31T19:18:15').toISOString(), + updated_at: moment('2014-10-06T15:23:54').toISOString(), + tags: [{name: 'tag1'}, {name: 'tag2'}, {name: 'tag3'}], + author: { + name: 'Author name', + url: 'http//:testauthorurl.com', + slug: 'Author', + image: null, + website: 'http://authorwebsite.com' + } + }; + + helpers.ghost_head.call({relativeUrl: '/post/', version: '0.3.0', post: post}).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('\n \n' + ' \n' + ' \n' + ' \n' + @@ -70,10 +178,53 @@ describe('{{ghost_head}} helper', function () { ' \n' + ' \n' + ' \n' + - ' \n' + ' \n' + ' \n' + - ' \n' + + ' \n \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n \n' + + ' \n\n' + + ' \n' + + ' '); + + done(); + }).catch(done); + }); + + it('does not return structured data if useStructuredData is set to false in config file', function (done) { + utils.overrideConfig({ + privacy: { + useStructuredData: false + } + }); + + var post = { + meta_description: 'blog description', + title: 'Welcome to Ghost', + image: '/test-image.png', + published_at: moment('2008-05-31T19:18:15').toISOString(), + updated_at: moment('2014-10-06T15:23:54').toISOString(), + tags: [{name: 'tag1'}, {name: 'tag2'}, {name: 'tag3'}], + author: { + name: 'Author name', + url: 'http//:testauthorurl.com', + slug: 'Author', + image: '/test-author-image.png', + website: 'http://authorwebsite.com' + } + }; + + helpers.ghost_head.call({relativeUrl: '/post/', version: '0.3.0', post: post}).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('\n' + ' \n' + ' ');