From 3229508c5496bee79dc871c87846183913b873ed Mon Sep 17 00:00:00 2001 From: cobbspur Date: Thu, 5 Feb 2015 13:06:36 +0000 Subject: [PATCH] Adds structured data to first index/tag/author page Closes #4677 - Tests if page is first page or paginated - Adds relevant structured data to index/tag/author page - Does not add structured data on paginated pages - For author structured data, cover image overrides image - blog cover image is made absolute by image helper - Tests updated to use regular expressions and new tests --- core/server/helpers/ghost_head.js | 364 ++++++--- .../unit/server_helpers/ghost_head_spec.js | 769 +++++++++++++----- 2 files changed, 809 insertions(+), 324 deletions(-) diff --git a/core/server/helpers/ghost_head.js b/core/server/helpers/ghost_head.js index 3fda5fb358..0c0932cead 100644 --- a/core/server/helpers/ghost_head.js +++ b/core/server/helpers/ghost_head.js @@ -21,143 +21,295 @@ var hbs = require('express-hbs'), excerpt = require('./excerpt'), tagsHelper = require('./tags'), imageHelper = require('./image'), + blog, ghost_head; +function getImage(ops, context, contextObject) { + if (context === 'home' || context === 'author') { + contextObject.image = contextObject.cover; + } + + ops.push(imageHelper.call(contextObject, {hash: {absolute: true}})); + + if (context === 'post' && contextObject.author) { + ops.push(imageHelper.call(contextObject.author, {hash: {absolute: true}})); + } + + return ops; +} + +function getPaginationUrls(pagination, relativeUrl, secure, head) { + var trimmedUrl, next, prev, + trimmedUrlpattern = /.+(?=\/page\/\d*\/)/, + tagOrAuthorPattern = /\/(tag)|(author)\//; + + trimmedUrl = relativeUrl.match(trimmedUrlpattern); + if (pagination.prev) { + prev = (pagination.prev > 1 ? '/page/' + pagination.prev + '/' : '/'); + prev = (trimmedUrl) ? trimmedUrl + prev : prev; + head.push('' + ); + } + if (pagination.next) { + next = '/page/' + pagination.next + '/'; + if (trimmedUrl) { + next = trimmedUrl + next; + } else if (tagOrAuthorPattern.test(relativeUrl)) { + next = relativeUrl.slice(0, -1) + next; + } + head.push('' + ); + } + return head; +} + +function addContextMetaData(context, data, metaData) { + // escaped data + metaData.metaTitle = hbs.handlebars.Utils.escapeExpression(metaData.metaTitle); + metaData.metaDescription = metaData.metaDescription ? hbs.handlebars.Utils.escapeExpression(metaData.metaDescription) : null; + + if (context === 'author') { + metaData.authorUrl = hbs.handlebars.Utils.escapeExpression(blog.url + '/author/' + data.author.slug); + metaData.ogType = 'profile'; + } else if (context === 'post') { + metaData.publishedDate = moment(data.post.published_at).toISOString(); + metaData.modifiedDate = moment(data.post.updated_at).toISOString(); + metaData.authorUrl = hbs.handlebars.Utils.escapeExpression(blog.url + '/author/' + data.post.author.slug); + metaData.ogType = 'article'; + } + return metaData; +} + +function initMetaData(context, data, results) { + var metaData = { + url: results[0].value(), + metaDescription: results[1].value() || null, + metaTitle: results[2].value(), + coverImage: results.length > 3 ? results[3].value() : null, + authorImage: results.length > 4 ? results[4].value() : null, + publishedDate: null, + modifiedDate: null, + tags: null, + card: 'summary', + authorUrl: null, + ogType: 'website', + keywords: null, + blog: blog, + title: blog.title + }; + + if (!metaData.metaDescription) { + if (context === 'post') { + metaData.metaDescription = excerpt.call(data.post, {hash: {words: '40'}}).string + '...'; + } else if (context === 'tag') { + metaData.metaDescription = data.tag.description ? data.tag.description : null; + } + } + return addContextMetaData(context, data, metaData); +} + +function getStructuredData(metaData) { + var structuredData; + + if (metaData.coverImage) { + metaData.card = 'summary_large_image'; + } + + structuredData = { + 'og:site_name': metaData.title, + 'og:type': metaData.ogType, + 'og:title': metaData.metaTitle, + 'og:description': metaData.metaDescription, + 'og:url': metaData.url, + 'og:image': metaData.coverImage, + 'article:published_time': metaData.publishedDate, + 'article:modified_time': metaData.modifiedDate, + 'article:tag': metaData.tags, + 'twitter:card': metaData.card, + 'twitter:title': metaData.metaTitle, + 'twitter:description': metaData.metaDescription, + 'twitter:url': metaData.url, + 'twitter:image:src': metaData.coverImage + }; + + return structuredData; +} + +// Creates the final schema object with values that are not null +function trimSchema(schema) { + var schemaObject = {}; + + _.each(schema, function (value, key) { + if (value !== null && value !== undefined) { + schemaObject[key] = value; + } + }); + return schemaObject; +} + +function getPostSchema(metaData, data) { + var schema = { + '@context': 'http://schema.org', + '@type': 'Article', + publisher: metaData.title, + author: { + '@type': 'Person', + name: data.post.author.name, + image: metaData.authorImage, + url: metaData.authorUrl, + sameAs: data.post.author.website || null, + description: data.post.author.bio || null + }, + headline: metaData.metaTitle, + url: metaData.url, + datePublished: metaData.publishedDate, + dateModified: metaData.modifiedDate, + image: metaData.coverImage, + keywords: metaData.keywords, + description: metaData.metaDescription + }; + return trimSchema(schema); +} + +function getTagSchema(metaData, data) { + var schema = { + '@context': 'http://schema.org', + '@type': 'Series', + publisher: metaData.title, + url: metaData.url, + image: metaData.coverImage, + name: data.tag.name, + description: metaData.metaDescription + }; + + return trimSchema(schema); +} + +function getAuthorSchema(metaData, data) { + var schema = { + '@context': 'http://schema.org', + '@type': 'Person', + sameAs: data.author.website || null, + publisher: metaData.title, + name: data.author.name, + url: metaData.authorUrl, + image: metaData.coverImage, + description: metaData.metaDescription + }; + + return trimSchema(schema); +} + +function getHomeSchema(metaData) { + var schema = { + '@context': 'http://schema.org', + '@type': 'Website', + publisher: metaData.title, + url: metaData.url, + image: metaData.coverImage, + description: metaData.metaDescription + }; + + return trimSchema(schema); +} + +function chooseSchema(metaData, context, data) { + if (context === 'post') { + return getPostSchema(metaData, data); + } else if (context === 'home') { + return getHomeSchema(metaData); + } else if (context === 'tag') { + return getTagSchema(metaData, data); + } else if (context === 'author') { + return getAuthorSchema(metaData, data); + } +} + +function finaliseStructuredData(structuredData, tags, head) { + var type; + _.each(structuredData, function (content, property) { + if (property === 'article:tag') { + _.each(tags, function (tag) { + if (tag !== '') { + tag = hbs.handlebars.Utils.escapeExpression(tag.trim()); + head.push(''); + } + }); + head.push(''); + } else if (content !== null && content !== undefined) { + type = property.substring(0, 7) === 'twitter' ? 'name' : 'property'; + head.push(''); + } + }); + return head; +} + +function finaliseSchema(schema, head) { + head.push('\n' + ); + return head; +} + ghost_head = function (options) { + // create a shortcut for theme config + blog = config.theme; + /*jshint unused:false*/ var self = this, - blog = config.theme, useStructuredData = !config.isPrivacyDisabled('useStructuredData'), head = [], majorMinor = /^(\d+\.)?(\d+)/, trimmedVersion = this.version, - trimmedUrlpattern = /.+(?=\/page\/\d*\/)/, - tagOrAuthorPattern = /\/(tag)|(author)\//, - trimmedUrl, next, prev, tags, ops = [], structuredData, - coverImage, authorImage, keywords, schema, - title = hbs.handlebars.Utils.escapeExpression(blog.title); + title = hbs.handlebars.Utils.escapeExpression(blog.title), + context = self.context[0], + contextObject = self[context] || blog; 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, options)); ops.push(meta_title.call(self, options)); - if (self.post) { - ops.push(imageHelper.call(self.post, {hash: {absolute:true}})); - - if (self.post.author) { - ops.push(imageHelper.call(self.post.author, {hash: {absolute:true}})); - } - } + ops = getImage(ops, context, contextObject); // Resolves promises then push pushes meta data into ghost_head return Promise.settle(ops).then(function (results) { - var url = results[0].value(), - metaDescription = results[1].value(), - metaTitle = results[2].value(), - coverImage = results.length > 3 ? results[3].value() : null, - authorImage = results.length > 4 ? results[4].value() : null, - publishedDate, modifiedDate, - tags = tagsHelper.call(self.post, {hash: {autolink: 'false'}}).string.split(','), - card = 'summary', - type, authorUrl; + var metaData = initMetaData(context, self, results), + tags = tagsHelper.call(self.post, {hash: {autolink: 'false'}}).string.split(','); - if (!metaDescription) { - metaDescription = excerpt.call(self.post, {hash: {words: '40'}}).string; - } + // If there are tags - build the keywords metaData string if (tags[0] !== '') { - keywords = hbs.handlebars.Utils.escapeExpression(tagsHelper.call(self.post, {hash: {autolink: 'false', separator: ', '}}).string); + metaData.keywords = hbs.handlebars.Utils.escapeExpression(tagsHelper.call(self.post, + {hash: {autolink: 'false', separator: ', '}} + ).string); } - head.push(''); + // head is our main array that holds our meta data + head.push(''); + + // Generate context driven pagination urls if (self.pagination) { - trimmedUrl = self.relativeUrl.match(trimmedUrlpattern); - if (self.pagination.prev) { - prev = (self.pagination.prev > 1 ? prev = '/page/' + self.pagination.prev + '/' : prev = '/'); - prev = (trimmedUrl) ? trimmedUrl + prev : prev; - head.push(''); - } - if (self.pagination.next) { - next = '/page/' + self.pagination.next + '/'; - if (trimmedUrl) { - next = trimmedUrl + next; - } else if (tagOrAuthorPattern.test(self.relativeUrl)) { - next = self.relativeUrl.slice(0, -1) + next; - } - head.push(''); - } + getPaginationUrls(self.pagination, self.relativeUrl, self.secure, head); } // Test to see if we are on a post page and that Structured data has not been disabled in config.js - if (self.post && useStructuredData) { - publishedDate = moment(self.post.published_at).toISOString(); - modifiedDate = moment(self.post.updated_at).toISOString(); - - if (coverImage) { - card = 'summary_large_image'; - } - - // escaped data - metaTitle = hbs.handlebars.Utils.escapeExpression(metaTitle); - metaDescription = hbs.handlebars.Utils.escapeExpression(metaDescription + '...'); - authorUrl = hbs.handlebars.Utils.escapeExpression(blog.url + '/author/' + self.post.author.slug); - - schema = { - '@context': 'http://schema.org', - '@type': 'Article', - publisher: title, - author: { - '@type': 'Person', - name: self.post.author.name, - image: authorImage, - url: authorUrl, - sameAs: self.post.author.website - }, - headline: metaTitle, - url: url, - datePublished: publishedDate, - dateModified: modifiedDate, - image: coverImage, - keywords: keywords, - description: metaDescription - }; - - structuredData = { - 'og:site_name': 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:tag': tags, - 'twitter:card': card, - 'twitter:title': metaTitle, - 'twitter:description': metaDescription, - 'twitter:url': url, - 'twitter:image:src': coverImage - }; + if (context !== 'paged' && context !== 'page' && useStructuredData) { + // Create context driven OpenGraph and Twitter meta data + structuredData = getStructuredData(metaData); + // Create context driven JSONLD object + schema = chooseSchema(metaData, context, self); head.push(''); - _.each(structuredData, function (content, property) { - if (property === 'article:tag') { - _.each(tags, function (tag) { - if (tag !== '') { - tag = hbs.handlebars.Utils.escapeExpression(tag.trim()); - head.push(''); - } - }); - head.push(''); - } else if (content !== null && content !== undefined) { - type = property.substring(0, 7) === 'twitter' ? 'name' : 'property'; - head.push(''); - } - }); + // Formats structured data and pushes to head array + finaliseStructuredData(structuredData, tags, head); head.push(''); - head.push('\n'); + // Formats schema script/JSONLD data and pushes to head array + finaliseSchema(schema, head); } 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 7d6c7e5977..b6df999947 100644 --- a/core/test/unit/server_helpers/ghost_head_spec.js +++ b/core/test/unit/server_helpers/ghost_head_spec.js @@ -14,22 +14,26 @@ var should = require('should'), describe('{{ghost_head}} helper', function () { var sandbox; - before(function () { utils.loadHelpers(); - utils.overrideConfig({ - url: 'http://testurl.com/', - theme: { - title: 'Ghost' - } - }); - }); - - after(function () { - utils.restoreConfig(); }); describe('without Code Injection', function () { + before(function () { + utils.overrideConfig({ + url: 'http://testurl.com/', + theme: { + title: 'Ghost', + description: 'blog description', + cover: '/content/images/blog-cover.png' + } + }); + }); + + after(function () { + utils.restoreConfig(); + }); + beforeEach(function () { sandbox = sinon.sandbox.create(); sandbox.stub(api.settings, 'read', function () { @@ -49,15 +53,241 @@ describe('{{ghost_head}} helper', function () { should.exist(handlebars.helpers.ghost_head); }); - it('returns meta tag string', function (done) { + it('returns meta tag string on paginated index page without structured data and schema', function (done) { helpers.ghost_head.call( - {version: '0.3.0', post: false}, - {data: {root: {context: []}}} + {version: '0.3.0', relativeUrl: '/page/2/', context: ['paged', 'index']}, + {data: {root: {context: ['paged', 'index']}}} ).then(function (rendered) { should.exist(rendered); - rendered.string.should.equal('\n' + - ' \n' + - ' '); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.not.match(//); + + done(); + }).catch(done); + }); + + it('returns structured data on first index page', function (done) { + helpers.ghost_head.call( + {version: '0.3.0', relativeUrl: '/', context: ['home', 'index']}, + {data: {root: {context: ['home', 'index']}}} + ).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(/\n\n' + - ' \n' + - ' '); + + 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(re1); + rendered.string.should.match(re2); + 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(/\n\n' + - ' \n' + - ' '); + + 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(re1); + rendered.string.should.match(re2); + 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(/\n\n' + - ' \n' + - ' '); + + 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(re1); + rendered.string.should.match(re2); + rendered.string.should.not.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(/\n\n' + - ' \n' + - ' '); + + 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.not.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.not.match(//); + rendered.string.should.match(/"@context": "http:\/\/schema.org"/); + rendered.string.should.match(/"@type": "Article"/); + rendered.string.should.match(/"publisher": "Ghost"/); + rendered.string.should.match(/"author": {/); + rendered.string.should.match(/"@type": "Person"/); + rendered.string.should.match(/"name": "Author name"/); + rendered.string.should.not.match(/"image\"/); + rendered.string.should.match(/"url": "http:\/\/testurl.com\/author\/Author"/); + rendered.string.should.match(/"sameAs": "http:\/\/authorwebsite.com"/); + rendered.string.should.match(/"headline": "Welcome to Ghost"/); + rendered.string.should.match(/"url": "http:\/\/testurl.com\/post\/"/); + rendered.string.should.match(re3); + rendered.string.should.match(re4); + rendered.string.should.match(/"keywords": "tag1, tag2, tag3"/); + rendered.string.should.match(/"description": "blog description"/); + rendered.string.should.match(//); + rendered.string.should.match(//); done(); }).catch(done); }); - it('does not return structured data if useStructuredData is set to false in config file', function (done) { + it('returns canonical URL', function (done) { + helpers.ghost_head.call( + {version: '0.3.0', relativeUrl: '/about/', context: ['page']}, + {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.not.match(//); + + done(); + }).catch(done); + }); + + it('returns next & prev URL correctly for middle page', function (done) { + helpers.ghost_head.call( + {version: '0.3.0', relativeUrl: '/page/3/', context: ['paged', 'index'], pagination: {next: '4', prev: '2'}}, + {data: {root: {context: ['index', 'paged'], pagination: {total: 4, page: 3, next: 4, prev: 2}}}} + ).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.not.match(//); + + done(); + }).catch(done); + }); + + it('returns next & prev URL correctly for second page', function (done) { + helpers.ghost_head.call( + {version: '0.3.0', relativeUrl: '/page/2/', context: ['paged', 'index'], pagination: {next: '3', prev: '1'}}, + {data: {root: {context: ['index', 'paged'], pagination: {total: 3, page: 2, next: 3, prev: 1}}}} + ).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.not.match(//); + + done(); + }).catch(done); + }); + + describe('with /blog subdirectory', function () { + before(function () { + utils.overrideConfig({ + url: 'http://testurl.com/blog/', + theme: { + title: 'Ghost', + description: 'blog description', + cover: '/content/images/blog-cover.png' + } + }); + }); + + after(function () { + utils.restoreConfig(); + }); + + it('returns correct rss url with subdirectory', function (done) { + helpers.ghost_head.call( + {version: '0.3.0', context: ['paged', 'index']}, + {data: {root: {context: []}}} + ).then(function (rendered) { + should.exist(rendered); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + + done(); + }).catch(done); + }); + }); + }); + + describe('with useStructuredData is set to false in config file', function () { + before(function () { utils.overrideConfig({ + url: 'http://testurl.com/', + theme: { + title: 'Ghost', + description: 'blog description', + cover: '/content/images/blog-cover.png' + }, privacy: { useStructuredData: false } }); + sandbox = sinon.sandbox.create(); + sandbox.stub(api.settings, 'read', function () { + return Promise.resolve({ + settings: [ + {value: ''} + ] + }); + }); + }); + after(function () { + utils.restoreConfig(); + sandbox.restore(); + }); + + it('does not return structured data', function (done) { var post = { meta_description: 'blog description', title: 'Welcome to Ghost', @@ -315,91 +717,19 @@ describe('{{ghost_head}} helper', function () { }; helpers.ghost_head.call( - {relativeUrl: '/post/', version: '0.3.0', post: post}, + {relativeUrl: '/post/', version: '0.3.0', context: ['post'], post: post}, {data: {root: {context: ['post']}}} ).then(function (rendered) { should.exist(rendered); - rendered.string.should.equal('\n' + - ' \n' + - ' '); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.not.match(//); done(); }).catch(done); }); - - it('returns canonical URL', function (done) { - helpers.ghost_head.call( - {version: '0.3.0', relativeUrl: '/about/'}, - {data: {root: {context: ['page']}}} - ).then(function (rendered) { - should.exist(rendered); - rendered.string.should.equal('\n' + - ' \n' + - ' '); - - done(); - }).catch(done); - }); - - it('returns next & prev URL correctly for middle page', function (done) { - helpers.ghost_head.call( - {version: '0.3.0', relativeUrl: '/page/3/', pagination: {next: '4', prev: '2'}}, - {data: {root: {context: ['index', 'paged'], pagination: {total: 4, page: 3, next: 4, prev: 2}}}} - ).then(function (rendered) { - should.exist(rendered); - rendered.string.should.equal('\n' + - ' \n' + - ' \n' + - ' \n' + - ' '); - done(); - }).catch(done); - }); - - it('returns next & prev URL correctly for second page', function (done) { - helpers.ghost_head.call( - {version: '0.3.0', relativeUrl: '/page/2/', pagination: {next: '3', prev: '1'}}, - {data: {root: {context: ['index', 'paged'], pagination: {total: 3, page: 2, next: 3, prev: 1}}}} - ).then(function (rendered) { - should.exist(rendered); - rendered.string.should.equal('\n' + - ' \n' + - ' \n' + - ' \n' + - ' '); - done(); - }).catch(done); - }); - - describe('with /blog subdirectory', function () { - before(function () { - utils.overrideConfig({ - url: 'http://testurl.com/blog/', - theme: { - title: 'Ghost' - } - }); - }); - - after(function () { - utils.restoreConfig(); - }); - - it('returns correct rss url with subdirectory', function (done) { - helpers.ghost_head.call( - {version: '0.3.0'}, - {data: {root: {context: []}}} - ).then(function (rendered) { - should.exist(rendered); - rendered.string.should.equal('\n' + - ' \n' + - ' '); - - done(); - }).catch(done); - }); - }); }); describe('with Code Injection', function () { @@ -410,10 +740,13 @@ describe('{{ghost_head}} helper', function () { settings: [{value: ''}] }); }); + utils.overrideConfig({ url: 'http://testurl.com/', theme: { - title: 'Ghost' + title: 'Ghost', + description: 'blog description', + cover: '/content/images/blog-cover.png' } }); }); @@ -425,14 +758,14 @@ describe('{{ghost_head}} helper', function () { it('returns meta tag plus injected code', function (done) { helpers.ghost_head.call( - {version: '0.3.0', post: false}, + {version: '0.3.0', context: ['paged', 'index'], post: false}, {data: {root: {context: []}}} ).then(function (rendered) { should.exist(rendered); - rendered.string.should.equal('\n' + - ' \n' + - ' \n' + - ' '); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(//); + rendered.string.should.match(/