From cfbb7f6c6bef27d5b1cac1763155efc5eebcd9a3 Mon Sep 17 00:00:00 2001 From: Aileen Nowak Date: Thu, 3 Aug 2017 15:48:39 +0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=20Facebook=20and=20Twitter=20data?= =?UTF-8?q?=20per=20post=20feature=20(#8827)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #8334 - adds title, image and description to structured data to be rendered as open graph and twitter data. - if meta title and description for a post exists already, the custom structured data will overwrite those for `og:` and `twitter:` data. `JSON-LD` (Schema.org`) is not affected and will stay the same. - adds tests - adds new og and twitter fields to schema incl. migration --- core/server/data/meta/description.js | 12 +- core/server/data/meta/image-dimensions.js | 2 + core/server/data/meta/index.js | 10 ++ core/server/data/meta/og_image.js | 20 ++++ core/server/data/meta/structured_data.js | 17 +-- core/server/data/meta/title.js | 12 +- core/server/data/meta/twitter_image.js | 20 ++++ .../versions/1.5/1-og-twitter-post.js | 100 ++++++++++++++++ core/server/data/schema/schema.js | 8 +- core/test/unit/metadata/description_spec.js | 42 +++++++ .../unit/metadata/image-dimensions_spec.js | 102 +++++++++++----- core/test/unit/metadata/og_image_spec.js | 84 +++++++++++++ .../unit/metadata/structured_data_spec.js | 85 +++++++++++++- core/test/unit/metadata/title_spec.js | 45 +++++++ core/test/unit/metadata/twitter_image_spec.js | 84 +++++++++++++ core/test/unit/migration_spec.js | 2 +- .../unit/server_helpers/ghost_head_spec.js | 111 ++++++++++++++++-- 17 files changed, 705 insertions(+), 51 deletions(-) create mode 100644 core/server/data/meta/og_image.js create mode 100644 core/server/data/meta/twitter_image.js create mode 100644 core/server/data/migrations/versions/1.5/1-og-twitter-post.js create mode 100644 core/test/unit/metadata/og_image_spec.js create mode 100644 core/test/unit/metadata/twitter_image_spec.js diff --git a/core/server/data/meta/description.js b/core/server/data/meta/description.js index 92c3adc92564..dc92e045da3d 100644 --- a/core/server/data/meta/description.js +++ b/core/server/data/meta/description.js @@ -1,11 +1,14 @@ var _ = require('lodash'), settingsCache = require('../../settings/cache'); -function getDescription(data, root) { +function getDescription(data, root, options) { var description = '', + postSdDescription, context = root ? root.context : null, blogDescription = settingsCache.get('description'); + options = options ? options : {}; + // We only return meta_description if provided. Only exception is the Blog // description, which doesn't rely on meta_description. if (data.meta_description) { @@ -22,7 +25,12 @@ function getDescription(data, root) { } else if (_.includes(context, 'tag') && data.tag) { description = data.tag.meta_description || ''; } else if ((_.includes(context, 'post') || _.includes(context, 'page')) && data.post) { - description = data.post.meta_description || ''; + if (options && options.property) { + postSdDescription = options.property + '_description'; + description = data.post[postSdDescription] || ''; + } else { + description = data.post.meta_description || ''; + } } return (description || '').trim(); diff --git a/core/server/data/meta/image-dimensions.js b/core/server/data/meta/image-dimensions.js index ea189b328b71..f4115b5c5f9b 100644 --- a/core/server/data/meta/image-dimensions.js +++ b/core/server/data/meta/image-dimensions.js @@ -13,6 +13,7 @@ function getImageDimensions(metaData) { var fetch = { coverImage: getCachedImageSizeFromUrl(metaData.coverImage.url), authorImage: getCachedImageSizeFromUrl(metaData.authorImage.url), + ogImage: getCachedImageSizeFromUrl(metaData.ogImage.url), // CASE: check if logo has hard coded image dimension. In that case it's an `ico` file, which // is not supported by `image-size` and would produce an error logo: metaData.blog.logo && metaData.blog.logo.dimensions ? metaData.blog.logo.dimensions : getCachedImageSizeFromUrl(metaData.blog.logo.url) @@ -24,6 +25,7 @@ function getImageDimensions(metaData) { imageObj = { coverImage: resolve.coverImage, authorImage: resolve.authorImage, + ogImage: resolve.ogImage, logo: resolve.logo }; diff --git a/core/server/data/meta/index.js b/core/server/data/meta/index.js index 0ffa95fd9da1..abb8866d8c9a 100644 --- a/core/server/data/meta/index.js +++ b/core/server/data/meta/index.js @@ -20,6 +20,8 @@ var Promise = require('bluebird'), getPublishedDate = require('./published_date'), getModifiedDate = require('./modified_date'), getOgType = require('./og_type'), + getOgImage = require('./og_image'), + getTwitterImage = require('./twitter_image'), getStructuredData = require('./structured_data'), getSchema = require('./schema'), getExcerpt = require('./excerpt'); @@ -41,6 +43,14 @@ function getMetaData(data, root) { authorImage: { url: getAuthorImage(data, true) }, + ogImage: { + url: getOgImage(data, true) + }, + ogTitle: getTitle(data, root, {property: 'og'}), + ogDescription: getDescription(data, root, {property: 'og'}), + twitterImage: getTwitterImage(data, true), + twitterTitle: getTitle(data, root, {property: 'twitter'}), + twitterDescription: getDescription(data, root, {property: 'twitter'}), authorFacebook: getAuthorFacebook(data), creatorTwitter: getCreatorTwitter(data), keywords: getKeywords(data), diff --git a/core/server/data/meta/og_image.js b/core/server/data/meta/og_image.js new file mode 100644 index 000000000000..29071ee88b8a --- /dev/null +++ b/core/server/data/meta/og_image.js @@ -0,0 +1,20 @@ +var utils = require('../../utils'), + getContextObject = require('./context_object.js'), + _ = require('lodash'); + +function getOgImage(data) { + var context = data.context ? data.context : null, + contextObject = getContextObject(data, context); + + if (_.includes(context, 'post') || _.includes(context, 'page') || _.includes(context, 'amp')) { + if (contextObject.og_image) { + return utils.url.urlFor('image', {image: contextObject.og_image}, true); + } else if (contextObject.feature_image) { + return utils.url.urlFor('image', {image: contextObject.feature_image}, true); + } + } + + return null; +} + +module.exports = getOgImage; diff --git a/core/server/data/meta/structured_data.js b/core/server/data/meta/structured_data.js index 5df7d72d283b..91b87f829a14 100644 --- a/core/server/data/meta/structured_data.js +++ b/core/server/data/meta/structured_data.js @@ -11,23 +11,23 @@ function getStructuredData(metaData) { structuredData = { 'og:site_name': metaData.blog.title, 'og:type': metaData.ogType, - 'og:title': metaData.metaTitle, + 'og:title': metaData.ogTitle || metaData.metaTitle, // CASE: metaData.excerpt for post context is populated by either the custom excerpt, // the meta description, or the automated excerpt of 50 words. It is empty for any // other context and *always* uses the provided meta description fields. - 'og:description': metaData.excerpt || metaData.metaDescription, + 'og:description': metaData.ogDescription || metaData.excerpt || metaData.metaDescription, 'og:url': metaData.canonicalUrl, - 'og:image': metaData.coverImage.url, + 'og:image': metaData.ogImage.url || metaData.coverImage.url, 'article:published_time': metaData.publishedDate, 'article:modified_time': metaData.modifiedDate, 'article:tag': metaData.keywords, 'article:publisher': metaData.blog.facebook ? socialUrls.facebookUrl(metaData.blog.facebook) : undefined, 'article:author': metaData.authorFacebook ? socialUrls.facebookUrl(metaData.authorFacebook) : undefined, 'twitter:card': card, - 'twitter:title': metaData.metaTitle, - 'twitter:description': metaData.excerpt || metaData.metaDescription, + 'twitter:title': metaData.twitterTitle || metaData.metaTitle, + 'twitter:description': metaData.twitterDescription || metaData.excerpt || metaData.metaDescription, 'twitter:url': metaData.canonicalUrl, - 'twitter:image': metaData.coverImage.url, + 'twitter:image': metaData.twitterImage || metaData.coverImage.url, 'twitter:label1': metaData.authorName ? 'Written by' : undefined, 'twitter:data1': metaData.authorName, 'twitter:label2': metaData.keywords ? 'Filed under' : undefined, @@ -36,7 +36,10 @@ function getStructuredData(metaData) { 'twitter:creator': metaData.creatorTwitter || undefined }; - if (metaData.coverImage.dimensions) { + if (metaData.ogImage.dimensions) { + structuredData['og:image:width'] = metaData.ogImage.dimensions.width; + structuredData['og:image:height'] = metaData.ogImage.dimensions.height; + } else if (metaData.coverImage.dimensions) { structuredData['og:image:width'] = metaData.coverImage.dimensions.width; structuredData['og:image:height'] = metaData.coverImage.dimensions.height; } diff --git a/core/server/data/meta/title.js b/core/server/data/meta/title.js index 6cdf45855f68..88d166646387 100644 --- a/core/server/data/meta/title.js +++ b/core/server/data/meta/title.js @@ -1,13 +1,16 @@ var _ = require('lodash'), settingsCache = require('../../settings/cache'); -function getTitle(data, root) { +function getTitle(data, root, options) { var title = '', context = root ? root.context : null, + postSdTitle, blogTitle = settingsCache.get('title'), pagination = root ? root.pagination : null, pageString = ''; + options = options ? options : {}; + if (pagination && pagination.total > 1) { pageString = ' (Page ' + pagination.page + ')'; } @@ -32,7 +35,12 @@ function getTitle(data, root) { title = data.tag.meta_title || data.tag.name + ' - ' + blogTitle; // Post title } else if ((_.includes(context, 'post') || _.includes(context, 'page')) && data.post) { - title = data.post.meta_title || data.post.title; + if (options && options.property) { + postSdTitle = options.property + '_title'; + title = data.post[postSdTitle] || ''; + } else { + title = data.post.meta_title || data.post.title; + } // Fallback } else { title = blogTitle + pageString; diff --git a/core/server/data/meta/twitter_image.js b/core/server/data/meta/twitter_image.js new file mode 100644 index 000000000000..a3c266fb1540 --- /dev/null +++ b/core/server/data/meta/twitter_image.js @@ -0,0 +1,20 @@ +var utils = require('../../utils'), + getContextObject = require('./context_object.js'), + _ = require('lodash'); + +function getTwitterImage(data) { + var context = data.context ? data.context : null, + contextObject = getContextObject(data, context); + + if (_.includes(context, 'post') || _.includes(context, 'page') || _.includes(context, 'amp')) { + if (contextObject.twitter_image) { + return utils.url.urlFor('image', {image: contextObject.twitter_image}, true); + } else if (contextObject.feature_image) { + return utils.url.urlFor('image', {image: contextObject.feature_image}, true); + } + } + + return null; +} + +module.exports = getTwitterImage; diff --git a/core/server/data/migrations/versions/1.5/1-og-twitter-post.js b/core/server/data/migrations/versions/1.5/1-og-twitter-post.js new file mode 100644 index 000000000000..04ae10d5f537 --- /dev/null +++ b/core/server/data/migrations/versions/1.5/1-og-twitter-post.js @@ -0,0 +1,100 @@ +'use strict'; + +const Promise = require('bluebird'), + logging = require('../../../../logging'), + commands = require('../../../schema').commands, + table = 'posts', + column1 = 'og_image', + column2 = 'og_title', + column3 = 'og_description', + column4 = 'twitter_image', + column5 = 'twitter_title', + column6 = 'twitter_description', + message1 = 'Adding column: ' + table + '.' + column1, + message2 = 'Adding column: ' + table + '.' + column2, + message3 = 'Adding column: ' + table + '.' + column3, + message4 = 'Adding column: ' + table + '.' + column4, + message5 = 'Adding column: ' + table + '.' + column5, + message6 = 'Adding column: ' + table + '.' + column6; + +module.exports = function addCodeInjectionPostColumns(options) { + let transacting = options.transacting; + + return transacting.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + return Promise.reject(new Error('Table does not exist!')); + } + + return transacting.schema.hasColumn(table, column1); + }) + .then(function (exists) { + if (exists) { + logging.warn(message1); + return Promise.resolve(); + } + + logging.info(message1); + return commands.addColumn(table, column1, transacting); + }) + .then(function () { + return transacting.schema.hasColumn(table, column2); + }) + .then(function (exists) { + if (exists) { + logging.warn(message2); + return Promise.resolve(); + } + + logging.info(message2); + return commands.addColumn(table, column2, transacting); + }) + .then(function () { + return transacting.schema.hasColumn(table, column3); + }) + .then(function (exists) { + if (exists) { + logging.warn(message3); + return Promise.resolve(); + } + + logging.info(message3); + return commands.addColumn(table, column3, transacting); + }) + .then(function () { + return transacting.schema.hasColumn(table, column4); + }) + .then(function (exists) { + if (exists) { + logging.warn(message4); + return Promise.resolve(); + } + + logging.info(message4); + return commands.addColumn(table, column4, transacting); + }) + .then(function () { + return transacting.schema.hasColumn(table, column5); + }) + .then(function (exists) { + if (exists) { + logging.warn(message5); + return Promise.resolve(); + } + + logging.info(message5); + return commands.addColumn(table, column5, transacting); + }) + .then(function () { + return transacting.schema.hasColumn(table, column6); + }) + .then(function (exists) { + if (exists) { + logging.warn(message6); + return Promise.resolve(); + } + + logging.info(message6); + return commands.addColumn(table, column6, transacting); + }); +}; diff --git a/core/server/data/schema/schema.js b/core/server/data/schema/schema.js index fe223302e345..0aa48849f7d0 100644 --- a/core/server/data/schema/schema.js +++ b/core/server/data/schema/schema.js @@ -25,7 +25,13 @@ module.exports = { published_by: {type: 'string', maxlength: 24, nullable: true}, custom_excerpt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}}, codeinjection_head: {type: 'text', maxlength: 65535, nullable: true}, - codeinjection_foot: {type: 'text', maxlength: 65535, nullable: true} + codeinjection_foot: {type: 'text', maxlength: 65535, nullable: true}, + og_image: {type: 'string', maxlength: 2000, nullable: true}, + og_title: {type: 'string', maxlength: 300, nullable: true}, + og_description: {type: 'string', maxlength: 500, nullable: true}, + twitter_image: {type: 'string', maxlength: 2000, nullable: true}, + twitter_title: {type: 'string', maxlength: 300, nullable: true}, + twitter_description: {type: 'string', maxlength: 500, nullable: true} }, users: { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, diff --git a/core/test/unit/metadata/description_spec.js b/core/test/unit/metadata/description_spec.js index b930696f150d..1c8004592300 100644 --- a/core/test/unit/metadata/description_spec.js +++ b/core/test/unit/metadata/description_spec.js @@ -73,6 +73,48 @@ describe('getMetaDescription', function () { description.should.equal('Best post ever!'); }); + it('should return OG data post meta description if on root context contains post', function () { + var description = getMetaDescription({ + post: { + meta_description: 'Best post ever!', + og_description: 'My custom Facebook description!' + } + }, { + context: ['post'] + }, { + property: 'og' + }); + description.should.equal('My custom Facebook description!'); + }); + + it('should not return data post meta description if on root context contains post and called with OG property', function () { + var description = getMetaDescription({ + post: { + meta_description: 'Best post ever!', + og_description: '' + } + }, { + context: ['post'] + }, { + property: 'og' + }); + description.should.equal(''); + }); + + it('should return Twitter data post meta description if on root context contains post', function () { + var description = getMetaDescription({ + post: { + meta_description: 'Best post ever!', + twitter_description: 'My custom Twitter description!' + } + }, { + context: ['post'] + }, { + property: 'twitter' + }); + description.should.equal('My custom Twitter description!'); + }); + it('should return data post meta description if on root context contains post for an AMP post', function () { var description = getMetaDescription({ post: { diff --git a/core/test/unit/metadata/image-dimensions_spec.js b/core/test/unit/metadata/image-dimensions_spec.js index 5f05a68a8971..efe28f650904 100644 --- a/core/test/unit/metadata/image-dimensions_spec.js +++ b/core/test/unit/metadata/image-dimensions_spec.js @@ -28,6 +28,9 @@ describe('getImageDimensions', function () { authorImage: { url: 'http://mysite.com/author/image/url/me.jpg' }, + ogImage: { + url: 'http://mysite.com/content/image/super-facebook-image.jpg' + }, blog: { logo: { url: 'http://mysite.com/author/image/url/logo.jpg' @@ -47,21 +50,24 @@ describe('getImageDimensions', function () { should.exist(result); sizeOfStub.calledWith(metaData.coverImage.url).should.be.true(); sizeOfStub.calledWith(metaData.authorImage.url).should.be.true(); + sizeOfStub.calledWith(metaData.ogImage.url).should.be.true(); sizeOfStub.calledWith(metaData.blog.logo.url).should.be.true(); result.coverImage.should.have.property('dimensions'); result.coverImage.should.have.property('url'); - result.blog.logo.should.have.property('dimensions'); - result.coverImage.dimensions.should.have.property('height', 50); result.coverImage.dimensions.should.have.property('width', 50); - result.blog.logo.should.have.property('dimensions'); - result.blog.logo.dimensions.should.have.property('height', 50); - result.blog.logo.dimensions.should.have.property('width', 50); + result.coverImage.dimensions.should.have.property('height', 50); result.authorImage.should.have.property('dimensions'); - result.authorImage.dimensions.should.have.property('height', 50); + result.authorImage.should.have.property('url'); result.authorImage.dimensions.should.have.property('width', 50); + result.authorImage.dimensions.should.have.property('height', 50); + result.ogImage.should.have.property('dimensions'); + result.ogImage.should.have.property('url'); + result.ogImage.dimensions.should.have.property('width', 50); + result.ogImage.dimensions.should.have.property('height', 50); + result.blog.logo.should.have.property('dimensions'); result.blog.logo.should.have.property('url'); - result.authorImage.should.have.property('dimensions'); - result.authorImage.should.have.property('url'); + result.blog.logo.dimensions.should.have.property('width', 50); + result.blog.logo.dimensions.should.have.property('height', 50); done(); }).catch(done); }); @@ -74,6 +80,12 @@ describe('getImageDimensions', function () { authorImage: { url: null }, + ogImage: { + url: '' + }, + twitterImage: { + url: null + }, blog: { logo: { url: 'noUrl' @@ -89,13 +101,16 @@ describe('getImageDimensions', function () { should.exist(result); sizeOfStub.calledWith(metaData.coverImage.url).should.be.true(); sizeOfStub.calledWith(metaData.authorImage.url).should.be.true(); + sizeOfStub.calledWith(metaData.ogImage.url).should.be.true(); sizeOfStub.calledWith(metaData.blog.logo.url).should.be.true(); result.coverImage.should.not.have.property('dimensions'); - result.blog.logo.should.not.have.property('dimensions'); - result.authorImage.should.not.have.property('dimensions'); result.coverImage.should.have.property('url'); - result.blog.logo.should.have.property('url'); + result.authorImage.should.not.have.property('dimensions'); result.authorImage.should.have.property('url'); + result.ogImage.should.not.have.property('dimensions'); + result.ogImage.should.have.property('url'); + result.blog.logo.should.not.have.property('dimensions'); + result.blog.logo.should.have.property('url'); done(); }).catch(done); }); @@ -108,6 +123,9 @@ describe('getImageDimensions', function () { authorImage: { url: 'http://mysite.com/author/image/url/me.jpg' }, + ogImage: { + url: 'http://mysite.com/content/image/super-facebook-image.jpg' + }, blog: { logo: { url: 'http://mysite.com/author/image/url/favicon.ico', @@ -131,19 +149,24 @@ describe('getImageDimensions', function () { should.exist(result); sizeOfStub.calledWith(metaData.coverImage.url).should.be.true(); sizeOfStub.calledWith(metaData.authorImage.url).should.be.true(); + sizeOfStub.calledWith(metaData.ogImage.url).should.be.true(); sizeOfStub.calledWith(metaData.blog.logo.url).should.be.false(); result.coverImage.should.have.property('dimensions'); + result.coverImage.should.have.property('url'); result.coverImage.dimensions.should.have.property('height', 80); result.coverImage.dimensions.should.have.property('width', 480); - result.blog.logo.should.have.property('dimensions'); - result.blog.logo.dimensions.should.have.property('height', 60); - result.blog.logo.dimensions.should.have.property('width', 60); result.authorImage.should.have.property('dimensions'); + result.authorImage.should.have.property('url'); result.authorImage.dimensions.should.have.property('height', 80); result.authorImage.dimensions.should.have.property('width', 480); - result.coverImage.should.have.property('url'); + result.ogImage.should.have.property('dimensions'); + result.ogImage.should.have.property('url'); + result.ogImage.dimensions.should.have.property('height', 80); + result.ogImage.dimensions.should.have.property('width', 480); + result.blog.logo.should.have.property('dimensions'); result.blog.logo.should.have.property('url'); - result.authorImage.should.have.property('url'); + result.blog.logo.dimensions.should.have.property('height', 60); + result.blog.logo.dimensions.should.have.property('width', 60); done(); }).catch(done); }); @@ -156,6 +179,9 @@ describe('getImageDimensions', function () { authorImage: { url: 'http://mysite.com/author/image/url/me.jpg' }, + ogImage: { + url: 'http://mysite.com/content/image/super-facebook-image.jpg' + }, blog: { logo: { url: 'http://mysite.com/author/image/url/favicon.ico', @@ -179,19 +205,25 @@ describe('getImageDimensions', function () { should.exist(result); sizeOfStub.calledWith(metaData.coverImage.url).should.be.true(); sizeOfStub.calledWith(metaData.authorImage.url).should.be.true(); + sizeOfStub.calledWith(metaData.ogImage.url).should.be.true(); sizeOfStub.calledWith(metaData.blog.logo.url).should.be.false(); result.coverImage.should.have.property('dimensions'); + result.coverImage.should.have.property('url'); result.coverImage.dimensions.should.have.property('height', 480); result.coverImage.dimensions.should.have.property('width', 480); - result.blog.logo.should.have.property('dimensions'); - result.blog.logo.dimensions.should.have.property('height', 60); - result.blog.logo.dimensions.should.have.property('width', 60); result.authorImage.should.have.property('dimensions'); + result.authorImage.should.have.property('url'); result.authorImage.dimensions.should.have.property('height', 480); result.authorImage.dimensions.should.have.property('width', 480); - result.coverImage.should.have.property('url'); + result.ogImage.should.have.property('dimensions'); + result.ogImage.should.have.property('url'); + result.ogImage.dimensions.should.have.property('height', 480); + result.ogImage.dimensions.should.have.property('width', 480); + result.blog.logo.should.have.property('dimensions'); result.blog.logo.should.have.property('url'); - result.authorImage.should.have.property('url'); + result.blog.logo.dimensions.should.have.property('height', 60); + result.blog.logo.dimensions.should.have.property('width', 60); + done(); }).catch(done); }); @@ -204,6 +236,9 @@ describe('getImageDimensions', function () { authorImage: { url: 'http://mysite.com/author/image/url/me.jpg' }, + ogImage: { + url: 'http://mysite.com/content/image/super-facebook-image.jpg' + }, blog: { logo: { url: 'http://mysite.com/author/image/url/favicon.png' @@ -223,19 +258,24 @@ describe('getImageDimensions', function () { should.exist(result); sizeOfStub.calledWith(metaData.coverImage.url).should.be.true(); sizeOfStub.calledWith(metaData.authorImage.url).should.be.true(); + sizeOfStub.calledWith(metaData.ogImage.url).should.be.true(); sizeOfStub.calledWith(metaData.blog.logo.url).should.be.true(); + result.coverImage.should.have.property('url'); result.coverImage.should.have.property('dimensions'); result.coverImage.dimensions.should.have.property('height', 480); result.coverImage.dimensions.should.have.property('width', 480); + result.blog.logo.should.have.property('url'); result.blog.logo.should.have.property('dimensions'); result.blog.logo.dimensions.should.have.property('height', 60); result.blog.logo.dimensions.should.have.property('width', 60); + result.authorImage.should.have.property('url'); result.authorImage.should.have.property('dimensions'); result.authorImage.dimensions.should.have.property('height', 480); result.authorImage.dimensions.should.have.property('width', 480); - result.coverImage.should.have.property('url'); - result.blog.logo.should.have.property('url'); - result.authorImage.should.have.property('url'); + result.ogImage.should.have.property('url'); + result.ogImage.should.have.property('dimensions'); + result.ogImage.dimensions.should.have.property('height', 480); + result.ogImage.dimensions.should.have.property('width', 480); done(); }).catch(done); }); @@ -248,6 +288,9 @@ describe('getImageDimensions', function () { authorImage: { url: 'http://mysite.com/author/image/url/me.jpg' }, + ogImage: { + url: 'http://mysite.com/content/image/super-facebook-image.jpg' + }, blog: { logo: { url: 'http://mysite.com/author/image/url/logo.jpg' @@ -267,17 +310,22 @@ describe('getImageDimensions', function () { should.exist(result); sizeOfStub.calledWith(metaData.coverImage.url).should.be.true(); sizeOfStub.calledWith(metaData.authorImage.url).should.be.true(); + sizeOfStub.calledWith(metaData.ogImage.url).should.be.true(); sizeOfStub.calledWith(metaData.blog.logo.url).should.be.true(); result.coverImage.should.have.property('dimensions'); + result.coverImage.should.have.property('url'); result.coverImage.dimensions.should.have.property('height', 480); result.coverImage.dimensions.should.have.property('width', 80); - result.blog.logo.should.not.have.property('dimensions'); result.authorImage.should.have.property('dimensions'); + result.authorImage.should.have.property('url'); result.authorImage.dimensions.should.have.property('height', 480); result.authorImage.dimensions.should.have.property('width', 80); - result.coverImage.should.have.property('url'); + result.ogImage.should.have.property('dimensions'); + result.ogImage.should.have.property('url'); + result.ogImage.dimensions.should.have.property('height', 480); + result.ogImage.dimensions.should.have.property('width', 80); result.blog.logo.should.have.property('url'); - result.authorImage.should.have.property('url'); + result.blog.logo.should.not.have.property('dimensions'); done(); }).catch(done); }); diff --git a/core/test/unit/metadata/og_image_spec.js b/core/test/unit/metadata/og_image_spec.js new file mode 100644 index 000000000000..6150dc51e1fc --- /dev/null +++ b/core/test/unit/metadata/og_image_spec.js @@ -0,0 +1,84 @@ +var should = require('should'), + getOgImage = require('../../../server/data/meta/og_image'); + +describe('getOgImage', function () { + it('[home] should return null if not post context [home]', function () { + var ogImageUrl = getOgImage({ + context: ['home'], + home: {} + }); + should(ogImageUrl).equal(null); + }); + + it('should return null if not post context [author]', function () { + var ogImageUrl = getOgImage({ + context: ['author'], + author: {} + }); + should(ogImageUrl).equal(null); + }); + + it('should return null if not post context [tag]', function () { + var ogImageUrl = getOgImage({ + context: ['tag'], + author: {} + }); + should(ogImageUrl).equal(null); + }); + + it('should return absolute url for OG image in post context', function () { + var ogImageUrl = getOgImage({ + context: ['post'], + post: { + feature_image: '/content/images/my-test-image.jpg', + og_image: '/content/images/my-special-og-image.jpg' + } + }); + ogImageUrl.should.not.equal('/content/images/my-special-og-image.jpg'); + ogImageUrl.should.match(/\/content\/images\/my-special-og-image\.jpg$/); + }); + + it('should return absolute url for feature image in post context', function () { + var ogImageUrl = getOgImage({ + context: ['post'], + post: { + feature_image: '/content/images/my-test-image.jpg', + og_image: '' + } + }); + ogImageUrl.should.not.equal('/content/images/my-test-image.jpg'); + ogImageUrl.should.match(/\/content\/images\/my-test-image\.jpg$/); + }); + + it('should return absolute url for OG image in AMP context', function () { + var ogImageUrl = getOgImage({ + context: ['amp', 'post'], + post: { + feature_image: '/content/images/my-test-image.jpg', + og_image: '/content/images/my-special-og-image.jpg' + } + }); + ogImageUrl.should.not.equal('/content/images/my-special-og-image.jpg'); + ogImageUrl.should.match(/\/content\/images\/my-special-og-image\.jpg$/); + }); + + it('should return absolute url for feature image in AMP context', function () { + var ogImageUrl = getOgImage({ + context: ['amp', 'post'], + post: { + feature_image: '/content/images/my-test-image.jpg', + og_image: '' + } + }); + ogImageUrl.should.not.equal('/content/images/my-test-image.jpg'); + ogImageUrl.should.match(/\/content\/images\/my-test-image\.jpg$/); + }); + + it('should return null if missing image', function () { + var ogImageUrl = getOgImage({ + context: ['post'], + post: {} + }); + should(ogImageUrl).equal(null); + }); +}); diff --git a/core/test/unit/metadata/structured_data_spec.js b/core/test/unit/metadata/structured_data_spec.js index f1afe840ba78..fb3ba1905508 100644 --- a/core/test/unit/metadata/structured_data_spec.js +++ b/core/test/unit/metadata/structured_data_spec.js @@ -2,7 +2,7 @@ var should = require('should'), getStructuredData = require('../../../server/data/meta/structured_data'); describe('getStructuredData', function () { - it('should return structured data from metadata', function (done) { + it('should return structured data from metadata per post', function (done) { var metadata = { blog: { title: 'Blog Title', @@ -22,6 +22,14 @@ describe('getStructuredData', function () { height: 500 } }, + ogImage: { + url: null + }, + twitterImage: null, + ogTitle: '', + ogDescription: '', + twitterTitle: '', + twitterDescription: '', authorFacebook: 'testpage', creatorTwitter: '@twitterpage', keywords: ['one', 'two', 'tag'], @@ -57,6 +65,73 @@ describe('getStructuredData', function () { done(); }); + it('should return structured data from metadata with provided og and twitter images per post', function (done) { + var metadata = { + blog: { + title: 'Blog Title', + facebook: 'testuser', + twitter: '@testuser' + }, + authorName: 'Test User', + ogType: 'article', + metaTitle: 'Post Title', + canonicalUrl: 'http://mysite.com/post/my-post-slug/', + publishedDate: '2015-12-25T05:35:01.234Z', + modifiedDate: '2016-01-21T22:13:05.412Z', + coverImage: { + url: 'http://mysite.com/content/image/mypostcoverimage.jpg', + dimensions: { + width: 500, + height: 500 + } + }, + ogImage: { + url: 'http://mysite.com/content/image/mypostogimage.jpg', + dimensions: { + width: 20, + height: 100 + } + }, + twitterImage: 'http://mysite.com/content/image/myposttwitterimage.jpg', + ogTitle: 'Custom Facebook title', + ogDescription: 'Custom Facebook description', + twitterTitle: 'Custom Twitter title', + twitterDescription: 'Custom Twitter description', + authorFacebook: 'testpage', + creatorTwitter: '@twitterpage', + keywords: ['one', 'two', 'tag'], + metaDescription: 'Post meta description' + }, structuredData = getStructuredData(metadata); + + should.deepEqual(structuredData, { + 'article:modified_time': '2016-01-21T22:13:05.412Z', + 'article:published_time': '2015-12-25T05:35:01.234Z', + 'article:tag': ['one', 'two', 'tag'], + 'article:publisher': 'https://www.facebook.com/testuser', + 'article:author': 'https://www.facebook.com/testpage', + 'og:description': 'Custom Facebook description', + 'og:image': 'http://mysite.com/content/image/mypostogimage.jpg', + 'og:image:width': 20, + 'og:image:height': 100, + 'og:site_name': 'Blog Title', + 'og:title': 'Custom Facebook title', + 'og:type': 'article', + 'og:url': 'http://mysite.com/post/my-post-slug/', + 'twitter:card': 'summary_large_image', + 'twitter:data1': 'Test User', + 'twitter:data2': ['one', 'two', 'tag'].join(', '), + 'twitter:description': 'Custom Twitter description', + 'twitter:image': 'http://mysite.com/content/image/myposttwitterimage.jpg', + 'twitter:label1': 'Written by', + 'twitter:label2': 'Filed under', + 'twitter:title': 'Custom Twitter title', + 'twitter:url': 'http://mysite.com/post/my-post-slug/', + 'twitter:site': '@testuser', + 'twitter:creator': '@twitterpage' + }); + done(); + }); + it('should return structured data from metadata with no nulls', function (done) { var metadata = { blog: { @@ -74,6 +149,14 @@ describe('getStructuredData', function () { coverImage: { url: undefined }, + ogImage: { + url: null + }, + twitterImage: null, + ogTitle: null, + ogDescription: null, + twitterTitle: null, + twitterDescription: null, keywords: null, metaDescription: null }, structuredData = getStructuredData(metadata); diff --git a/core/test/unit/metadata/title_spec.js b/core/test/unit/metadata/title_spec.js index 3b100486941f..b8f9e6166a43 100644 --- a/core/test/unit/metadata/title_spec.js +++ b/core/test/unit/metadata/title_spec.js @@ -114,6 +114,51 @@ describe('getTitle', function () { title.should.equal('My awesome post!'); }); + it('should return OG post title if in post context', function () { + var title = getTitle({ + post: { + title: 'My awesome post!', + og_title: 'My Custom Facebook Title' + } + }, { + context: ['post'] + }, { + property: 'og' + }); + + title.should.equal('My Custom Facebook Title'); + }); + + it('should return twitter post title if in post context', function () { + var title = getTitle({ + post: { + title: 'My awesome post!', + twitter_title: 'My Custom Twitter Title' + } + }, { + context: ['post'] + }, { + property: 'twitter' + }); + + title.should.equal('My Custom Twitter Title'); + }); + + it('should not return default post title if in amp context and called with twitter property', function () { + var title = getTitle({ + post: { + title: 'My awesome post!', + twitter_title: '' + } + }, { + context: ['amp', 'post'] + }, { + property: 'twitter' + }); + + title.should.equal(''); + }); + it('should return post title if in amp context', function () { var title = getTitle({ post: { diff --git a/core/test/unit/metadata/twitter_image_spec.js b/core/test/unit/metadata/twitter_image_spec.js new file mode 100644 index 000000000000..d0e3a506b02b --- /dev/null +++ b/core/test/unit/metadata/twitter_image_spec.js @@ -0,0 +1,84 @@ +var should = require('should'), + getTwitterImage = require('../../../server/data/meta/twitter_image'); + +describe('getTwitterImage', function () { + it('[home] should return null if not post context [home]', function () { + var twitterImageUrl = getTwitterImage({ + context: ['home'], + home: {} + }); + should(twitterImageUrl).equal(null); + }); + + it('should return null if not post context [author]', function () { + var twitterImageUrl = getTwitterImage({ + context: ['author'], + author: {} + }); + should(twitterImageUrl).equal(null); + }); + + it('should return null if not post context [tag]', function () { + var twitterImageUrl = getTwitterImage({ + context: ['tag'], + author: {} + }); + should(twitterImageUrl).equal(null); + }); + + it('should return absolute url for Twitter image in post context', function () { + var twitterImageUrl = getTwitterImage({ + context: ['post'], + post: { + feature_image: '/content/images/my-test-image.jpg', + twitter_image: '/content/images/my-special-twitter-image.jpg' + } + }); + twitterImageUrl.should.not.equal('/content/images/my-special-twitter-image.jpg'); + twitterImageUrl.should.match(/\/content\/images\/my-special-twitter-image\.jpg$/); + }); + + it('should return absolute url for feature image in post context', function () { + var twitterImageUrl = getTwitterImage({ + context: ['post'], + post: { + feature_image: '/content/images/my-test-image.jpg', + twitter_image: '' + } + }); + twitterImageUrl.should.not.equal('/content/images/my-test-image.jpg'); + twitterImageUrl.should.match(/\/content\/images\/my-test-image\.jpg$/); + }); + + it('should return absolute url for Twitter image in AMP context', function () { + var twitterImageUrl = getTwitterImage({ + context: ['amp', 'post'], + post: { + feature_image: '/content/images/my-test-image.jpg', + twitter_image: '/content/images/my-special-twitter-image.jpg' + } + }); + twitterImageUrl.should.not.equal('/content/images/my-special-twitter-image.jpg'); + twitterImageUrl.should.match(/\/content\/images\/my-special-twitter-image\.jpg$/); + }); + + it('should return absolute url for feature image in AMP context', function () { + var twitterImageUrl = getTwitterImage({ + context: ['amp', 'post'], + post: { + feature_image: '/content/images/my-test-image.jpg', + twitter_image: '' + } + }); + twitterImageUrl.should.not.equal('/content/images/my-test-image.jpg'); + twitterImageUrl.should.match(/\/content\/images\/my-test-image\.jpg$/); + }); + + it('should return null if missing image', function () { + var twitterImageUrl = getTwitterImage({ + context: ['post'], + post: {} + }); + should(twitterImageUrl).equal(null); + }); +}); diff --git a/core/test/unit/migration_spec.js b/core/test/unit/migration_spec.js index 0af173de4e5e..4eb3dce1e3f4 100644 --- a/core/test/unit/migration_spec.js +++ b/core/test/unit/migration_spec.js @@ -19,7 +19,7 @@ var should = require('should'), // jshint ignore:line // both of which are required for migrations to work properly. describe('DB version integrity', function () { // Only these variables should need updating - var currentSchemaHash = 'e553b90c726502cff74b8dd3ed05be3b', + var currentSchemaHash = 'af4028653a7c0804f6bf7b98c50db5dc', currentFixturesHash = '6948548fee557adc738330522dc06d24'; // If this test is failing, then it is likely a change has been made that requires a DB version bump, diff --git a/core/test/unit/server_helpers/ghost_head_spec.js b/core/test/unit/server_helpers/ghost_head_spec.js index 77c356d8c9e2..d8e62a74af82 100644 --- a/core/test/unit/server_helpers/ghost_head_spec.js +++ b/core/test/unit/server_helpers/ghost_head_spec.js @@ -105,6 +105,12 @@ describe('{{ghost_head}} helper', function () { feature_image: '/content/images/test-image-about.png', published_at: moment('2008-05-31T19:18:15').toISOString(), updated_at: moment('2014-10-06T15:23:54').toISOString(), + og_image: '', + og_title: '', + og_description: '', + twitter_image: '', + twitter_title: '', + twitter_description: '', page: true, author: { name: 'Author name', @@ -158,6 +164,72 @@ describe('{{ghost_head}} helper', function () { }).catch(done); }); + it('returns structured data on static page with custom post structured data', function (done) { + var post = { + meta_description: 'all about our blog', + title: 'About', + feature_image: '/content/images/test-image-about.png', + og_image: '/content/images/test-og-image.png', + og_title: 'Custom Facebook title', + og_description: 'Custom Facebook description', + twitter_image: '/content/images/test-twitter-image.png', + twitter_title: 'Custom Twitter title', + twitter_description: 'Custom Twitter description', + published_at: moment('2008-05-31T19:18:15').toISOString(), + updated_at: moment('2014-10-06T15:23:54').toISOString(), + page: true, + author: { + name: 'Author name', + url: 'http://testauthorurl.com', + slug: 'Author', + profile_image: '/content/images/test-author-image.png', + website: 'http://authorwebsite.com', + facebook: 'testuser', + twitter: '@testuser', + bio: 'Author bio' + } + }; + + helpers.ghost_head.call( + {safeVersion: '0.3', relativeUrl: '/about/', context: ['page'], post: post}, + {data: {root: {context: ['page']}}} + ).then(function (rendered) { + should.exist(rendered); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(/