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
This commit is contained in:
cobbspur 2014-10-14 15:18:42 +02:00
parent ddb6230d4e
commit 23e98aa8dc
3 changed files with 229 additions and 29 deletions

View File

@ -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.
- Schema.org - http://schema.org/docs/documents.html
- Open Graph - http://ogp.me/
- Twitter cards - https://dev.twitter.com/cards/overview

View File

@ -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('<link rel="canonical" href="' + url + '" />');
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('<meta property="' + property + '" content="' + content + '" />');
});
// 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('<meta property="article:tag" content="' + tag.trim() + '" />');
if (property === 'article:tag') {
_.each(tags, function (tag) {
if (tag !== '') {
head.push('<meta property="' + property + '" content="' + tag.trim() + '" />');
}
});
head.push('');
} else if (content !== null && content !== undefined) {
if (property.substring(0, 7) === 'twitter') {
head.push('<meta name="' + property + '" content="' + content + '" />');
} else {
head.push('<meta property="' + property + '" content="' + content + '" />');
}
}
});
head.push('');
head.push('<script type="application/ld+json">\n' + JSON.stringify(schema, null, ' ') + '\n </script>\n');
}
head.push('<meta name="generator" content="Ghost ' + trimmedVersion + '" />');
head.push('<link rel="alternate" type="application/rss+xml" title="' +
_.escape(blog.title) + '" href="' + config.urlFor('rss') + '" />');

View File

@ -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('<link rel="canonical" href="http://testurl.com/post/" />\n' +
rendered.string.should.equal('<link rel="canonical" href="http://testurl.com/post/" />\n \n' +
' <meta property="og:site_name" content="Ghost" />\n' +
' <meta property="og:type" content="article" />\n' +
' <meta property="og:title" content="Welcome to Ghost" />\n' +
' <meta property="og:description" content="blog description..." />\n' +
' <meta property="og:url" content="http://testurl.com/post/" />\n' +
' <meta property="og:image" content="http://testurl.com/test-image.png" />\n' +
' <meta property="article:published_time" content="' + post.published_at + '" />\n' +
' <meta property="article:modified_time" content="' + post.updated_at + '" />\n' +
' <meta property="article:tag" content="tag1" />\n' +
' <meta property="article:tag" content="tag2" />\n' +
' <meta property="article:tag" content="tag3" />\n \n' +
' <meta name="twitter:card" content="summary_large_image" />\n' +
' <meta name="twitter:title" content="Welcome to Ghost" />\n' +
' <meta name="twitter:description" content="blog description..." />\n' +
' <meta name="twitter:url" content="http://testurl.com/post/" />\n' +
' <meta name="twitter:image:src" content="http://testurl.com/test-image.png" />\n \n' +
' <script type=\"application/ld+json\">\n{\n' +
' "@context": "http://schema.org",\n "@type": "Article",\n "publisher": "Ghost",\n' +
' "author": {\n "@type": "Person",\n "name": "Author name",\n ' +
' \"image\": \"http://testurl.com/test-author-image.png\",\n ' +
' "url": "http://testurl.com/author/Author",\n "sameAs": "http://authorwebsite.com"\n ' +
'},\n "headline": "Welcome to Ghost",\n "url": "http://testurl.com/post/",\n' +
' "datePublished": "' + post.published_at + '",\n "dateModified": "' + post.updated_at + '",\n' +
' "image": "http://testurl.com/test-image.png",\n "keywords": "tag1, tag2, tag3",\n' +
' "description": "blog description"\n}\n </script>\n\n' +
' <meta name="generator" content="Ghost 0.3" />\n' +
' <link rel="alternate" type="application/rss+xml" title="Ghost" href="/rss/" />');
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('<link rel="canonical" href="http://testurl.com/post/" />\n \n' +
' <meta property="og:site_name" content="Ghost" />\n' +
' <meta property="og:type" content="article" />\n' +
' <meta property="og:title" content="Welcome to Ghost" />\n' +
' <meta property="og:description" content="blog description..." />\n' +
' <meta property="og:url" content="http://testurl.com/post/" />\n' +
' <meta property="og:image" content="http://testurl.com/test-image.png" />\n' +
' <meta property="article:published_time" content="' + post.published_at + '" />\n' +
' <meta property="article:modified_time" content="' + post.updated_at + '" />\n \n' +
' <meta name="twitter:card" content="summary_large_image" />\n' +
' <meta name="twitter:title" content="Welcome to Ghost" />\n' +
' <meta name="twitter:description" content="blog description..." />\n' +
' <meta name="twitter:url" content="http://testurl.com/post/" />\n' +
' <meta name="twitter:image:src" content="http://testurl.com/test-image.png" />\n \n' +
' <script type=\"application/ld+json\">\n{\n' +
' "@context": "http://schema.org",\n "@type": "Article",\n "publisher": "Ghost",\n' +
' "author": {\n "@type": "Person",\n "name": "Author name",\n ' +
' \"image\": \"http://testurl.com/test-author-image.png\",\n ' +
' "url": "http://testurl.com/author/Author",\n "sameAs": "http://authorwebsite.com"\n ' +
'},\n "headline": "Welcome to Ghost",\n "url": "http://testurl.com/post/",\n' +
' "datePublished": "' + post.published_at + '",\n "dateModified": "' + post.updated_at + '",\n' +
' "image": "http://testurl.com/test-image.png",\n' +
' "description": "blog description"\n}\n </script>\n\n' +
' <meta name="generator" content="Ghost 0.3" />\n' +
' <link rel="alternate" type="application/rss+xml" title="Ghost" href="/rss/" />');
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('<link rel="canonical" href="http://testurl.com/post/" />\n \n' +
' <meta property="og:site_name" content="Ghost" />\n' +
' <meta property="og:type" content="article" />\n' +
' <meta property="og:title" content="Welcome to Ghost" />\n' +
@ -70,10 +178,53 @@ describe('{{ghost_head}} helper', function () {
' <meta property="og:url" content="http://testurl.com/post/" />\n' +
' <meta property="article:published_time" content="' + post.published_at + '" />\n' +
' <meta property="article:modified_time" content="' + post.updated_at + '" />\n' +
' <meta property="og:image" content="http://testurl.com/test-image.png" />\n' +
' <meta property="article:tag" content="tag1" />\n' +
' <meta property="article:tag" content="tag2" />\n' +
' <meta property="article:tag" content="tag3" />\n' +
' <meta property="article:tag" content="tag3" />\n \n' +
' <meta name="twitter:card" content="content" />\n' +
' <meta name="twitter:title" content="Welcome to Ghost" />\n' +
' <meta name="twitter:description" content="blog description..." />\n' +
' <meta name="twitter:url" content="http://testurl.com/post/" />\n \n' +
' <script type=\"application/ld+json\">\n{\n' +
' "@context": "http://schema.org",\n "@type": "Article",\n "publisher": "Ghost",\n' +
' "author": {\n "@type": "Person",\n "name": "Author name",\n ' +
' "url": "http://testurl.com/author/Author",\n "sameAs": "http://authorwebsite.com"\n ' +
'},\n "headline": "Welcome to Ghost",\n "url": "http://testurl.com/post/",\n' +
' "datePublished": "' + post.published_at + '",\n "dateModified": "' + post.updated_at + '",\n' +
' "keywords": "tag1, tag2, tag3",\n "description": "blog description"\n}\n </script>\n\n' +
' <meta name="generator" content="Ghost 0.3" />\n' +
' <link rel="alternate" type="application/rss+xml" title="Ghost" href="/rss/" />');
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('<link rel="canonical" href="http://testurl.com/post/" />\n' +
' <meta name="generator" content="Ghost 0.3" />\n' +
' <link rel="alternate" type="application/rss+xml" title="Ghost" href="/rss/" />');