Skip to content

Commit

Permalink
✨ Feature: {{reading_time}} theme helper (#9217)
Browse files Browse the repository at this point in the history
closes #9200

- Registered new server helper `{{reading_time}}`.
- Added new global util `word-count` based on the util in Ghost admin, which returns the number of words in an HTML string.
- Based on the word count of the post html, the helper calculated the estimated reading time:
   - 275 words per minute
   - additional 12 seconds when post has feature image
- Renders a string like 'x min red', unless reading time is less than a minute. In this case, the rendered string is '< 1 min read'.
  • Loading branch information
aileen authored and ErisDS committed Nov 6, 2017
1 parent 5dac1c9 commit dbd22d7
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 1 deletion.
2 changes: 2 additions & 0 deletions core/server/helpers/index.js
Expand Up @@ -28,6 +28,7 @@ coreHelpers.plural = require('./plural');
coreHelpers.post_class = require('./post_class');
coreHelpers.prev_post = require('./prev_next');
coreHelpers.next_post = require('./prev_next');
coreHelpers.reading_time = require('./reading_time');
coreHelpers.tags = require('./tags');
coreHelpers.title = require('./title');
coreHelpers.twitter_url = require('./twitter_url');
Expand All @@ -53,6 +54,7 @@ registerAllCoreHelpers = function registerAllCoreHelpers() {
registerThemeHelper('pagination', coreHelpers.pagination);
registerThemeHelper('plural', coreHelpers.plural);
registerThemeHelper('post_class', coreHelpers.post_class);
registerThemeHelper('reading_time', coreHelpers.reading_time);
registerThemeHelper('tags', coreHelpers.tags);
registerThemeHelper('title', coreHelpers.title);
registerThemeHelper('twitter_url', coreHelpers.twitter_url);
Expand Down
41 changes: 41 additions & 0 deletions core/server/helpers/reading_time.js
@@ -0,0 +1,41 @@
// # Reading Time Helper
//
// Usage: `{{reading_time}}`
//
// Returns estimated reading time for post

var proxy = require('./proxy'),
schema = require('../data/schema').checks,
SafeString = proxy.SafeString,
wordCountUtil = require('../utils/word-count');

module.exports = function reading_time() {// eslint-disable-line camelcase
var html,
wordsPerMinute = 275,
wordsPerSecond = wordsPerMinute / 60,
wordCount,
imageCount,
readingTimeSeconds,
readingTime;

// only calculate reading time for posts
if (!schema.isPost(this)) {
return null;
}

html = this.html;
imageCount = this.feature_image ? 1 : 0;
wordCount = wordCountUtil(html);
readingTimeSeconds = wordCount / wordsPerSecond;

// add 12 seconds to reading time if feature image is present
readingTimeSeconds = imageCount ? readingTimeSeconds + 12 : readingTimeSeconds;

if (readingTimeSeconds < 60) {
readingTime = '< 1 min read';
} else {
readingTime = `${Math.round(readingTimeSeconds / 60)} min read`;
}

return new SafeString(readingTime);
};
22 changes: 22 additions & 0 deletions core/server/utils/word-count.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion core/test/unit/helpers/index_spec.js
Expand Up @@ -11,7 +11,7 @@ describe('Helpers', function () {
ghostHelpers = [
'asset', 'author', 'body_class', 'content', 'date', 'encode', 'excerpt', 'facebook_url', 'foreach', 'get',
'ghost_foot', 'ghost_head', 'has', 'img_url', 'is', 'meta_description', 'meta_title', 'navigation',
'next_post', 'page_url', 'pagination', 'plural', 'post_class', 'prev_post', 'tags', 'title', 'twitter_url',
'next_post', 'page_url', 'pagination', 'plural', 'post_class', 'prev_post', 'reading_time', 'tags', 'title', 'twitter_url',
'url'
],
expectedHelpers = _.concat(hbsHelpers, ghostHelpers);
Expand Down
80 changes: 80 additions & 0 deletions core/test/unit/helpers/reading_time_spec.js
@@ -0,0 +1,80 @@
var should = require('should'), // jshint ignore:line

// Stuff we are testing
helpers = require('../../../server/helpers');

describe('{{reading_time}} helper', function () {
it('[success] renders reading time for less than one minute text correctly', function () {
var data = {
html: '<div class="kg-card-markdown"><p>This is a text example! Count me in ;)</p></div>',
title: 'Test',
slug: 'slug'
},
result = helpers.reading_time.call(data);

String(result).should.equal('< 1 min read');
});

it('[success] renders reading time for more than one minute text correctly', function () {
var data = {
html: '<div class="kg-card-markdown"><p>Ghost has a number of different user roles for your team</p>' +
'<h3 id="authors">Authors</h3><p>The base user level in Ghost is an author. Authors can write posts,' +
' edit their own posts, and publish their own posts. Authors are <strong>trusted</strong> users. If you ' +
'don\'t trust users to be allowed to publish their own posts, you shouldn\'t invite them to Ghost admin.</p>' +
'<h3 id="editors">Editors</h3><p>Editors are the 2nd user level in Ghost. Editors can do everything that an' +
' Author can do, but they can also edit and publish the posts of others - as well as their own. Editors can also invite new' + ' authors to the site.</p><h3 id="administrators">Administrators</h3><p>The top user level in Ghost is Administrator.' +
' Again, administrators can do everything that Authors and Editors can do, but they can also edit all site settings ' +
'and data, not just content. Additionally, administrators have full access to invite, manage or remove any other' +
' user of the site.</p><h3 id="theowner">The Owner</h3><p>There is only ever one owner of a Ghost site. ' +
'The owner is a special user which has all the same permissions as an Administrator, but with two exceptions: ' +
'The Owner can never be deleted. And in some circumstances the owner will have access to additional special settings ' +
'if applicable — for example, billing details, if using Ghost(Pro).</p><hr><p>It\'s a good idea to ask all of your' +
' users to fill out their user profiles, including bio and social links. These will populate rich structured data ' +
'for posts and generally create more opportunities for themes to fully populate their design.</p></div>',
title: 'Test',
slug: 'slug',
feature_image: '/content/images/someimage.jpg'
},
result = helpers.reading_time.call(data);

String(result).should.equal('1 min read');
});

it('[success] adds time for feature image', function () {
var data = {
html: '<div class="kg-card-markdown"><p>Ghost has a number of different user roles for your team</p>' +
'<h3 id="authors">Authors</h3><p>The base user level in Ghost is an author. Authors can write posts,' +
' edit their own posts, and publish their own posts. Authors are <strong>trusted</strong> users. If you ' +
'don\'t trust users to be allowed to publish their own posts, you shouldn\'t invite them to Ghost admin.</p>' +
'<h3 id="editors">Editors</h3><p>Editors are the 2nd user level in Ghost. Editors can do everything that an' +
' Author can do, but they can also edit and publish the posts of others - as well as their own. Editors can also invite new' + ' authors to the site.</p><h3 id="administrators">Administrators</h3><p>The top user level in Ghost is Administrator.' +
' Again, administrators can do everything that Authors and Editors can do, but they can also edit all site settings ' +
'and data, not just content. Additionally, administrators have full access to invite, manage or remove any other' +
' user of the site.</p><h3 id="theowner">The Owner</h3><p>There is only ever one owner of a Ghost site. ' +
'The owner is a special user which has all the same permissions as an Administrator, but with two exceptions: ' +
'The Owner can never be deleted. And in some circumstances the owner will have access to additional special settings ' +
'if applicable — for example, billing details, if using Ghost(Pro).</p><hr><p>It\'s a good idea to ask all of your' +
' users to fill out their user profiles, including bio and social links. These will populate rich structured data ',
title: 'Test',
slug: 'slug',
feature_image: '/content/images/someimage.jpg'
},
result = helpers.reading_time.call(data);

// The reading time for this HTML snippet would be 63 seconds without the image
// Adding the 12 additional seconds for the image results in a reading time > 1 minute
String(result).should.equal('1 min read');
});

it('[failure] does not render reading time when not post', function () {
var data = {
author: {
name: 'abc 123',
slug: 'abc123'
}
},
result = helpers.reading_time.call(data);

should.not.exist(result);
});
});
32 changes: 32 additions & 0 deletions core/test/unit/utils/word-count_spec.js
@@ -0,0 +1,32 @@
var should = require('should'), // jshint ignore:line
wordCount = require('../../../server/utils/word-count');

describe('wordCount', function () {
it('[success] can count words', function () {
var html = 'Some words here',
result = wordCount(html);

result.should.equal(3);
});

it('[success] sanitized HTML tags', function () {
var html = '<div class="kg-card-markdown"><p>This is a text example! Count me in ;)</p></div>',
result = wordCount(html);

result.should.equal(8);
});

it('[success] sanitized non alpha-numeric characters', function () {
var html = '<div class="kg-card-markdown"><p>This is a text example! I love Döner. Especially number 875.</p></div>',
result = wordCount(html);

result.should.equal(11);
});

it('[success] sanitized white space correctly', function () {
var html = ' <div class="kg-card-markdown"><p> This is a text example!\n Count me in ;)</p></div> ',
result = wordCount(html);

result.should.equal(8);
});
});

0 comments on commit dbd22d7

Please sign in to comment.