Added {{recommendations}} theme helper (#18340)
refs https://github.com/TryGhost/Product/issues/3940 - the {{recommendation}} helper fetches recommendations from the Content API and renders a HTML template with pre-defined CSS classes - the HTML template can be overridden in themes, by uploading a file under partials/recommendations.hbs - the CSS classes are not pre-defined, they need to be defined in individual themes - if there are no recommendations, nothing is rendered - the {{recommendations}} helper currently accepts "page", "limit", "filter", and "order" as options
This commit is contained in:
parent
3a5c233122
commit
d24c7c5fa6
116
ghost/core/core/frontend/helpers/recommendations.js
Normal file
116
ghost/core/core/frontend/helpers/recommendations.js
Normal file
@ -0,0 +1,116 @@
|
||||
/* Recommendations helper
|
||||
* Usage: `{{recommendations}}`
|
||||
*
|
||||
* Renders the template defined in `tpl/recommendations.hbs`
|
||||
* Can be overridden by themes by uploading a partial under `partials/recommendations.hbs`
|
||||
*
|
||||
* Available options: limit, order, filter, page
|
||||
*/
|
||||
const {config, api, prepareContextResource, settingsCache} = require('../services/proxy');
|
||||
const {templates, hbs} = require('../services/handlebars');
|
||||
|
||||
const logging = require('@tryghost/logging');
|
||||
const errors = require('@tryghost/errors');
|
||||
|
||||
const createFrame = hbs.handlebars.createFrame;
|
||||
|
||||
/**
|
||||
* Call the Recommendation Content API's browse method
|
||||
* @param {Object} apiOptions
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function fetchRecommendations(apiOptions) {
|
||||
let timer;
|
||||
|
||||
try {
|
||||
const controller = api.recommendationsPublic;
|
||||
let response;
|
||||
|
||||
const logLevel = config.get('optimization:getHelper:timeout:level') || 'error';
|
||||
const threshold = config.get('optimization:getHelper:timeout:threshold') || 5000;
|
||||
const apiResponse = controller.browse(apiOptions);
|
||||
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
logging[logLevel](new errors.HelperWarning({
|
||||
message: `{{#recommendations}} took longer than ${threshold}ms and was aborted`,
|
||||
code: 'ABORTED_RECOMMENDATIONS_HELPER',
|
||||
errorDetails: {
|
||||
api: 'recommendationsPublic.browse',
|
||||
apiOptions
|
||||
}
|
||||
}));
|
||||
|
||||
resolve({recommendations: []});
|
||||
}, threshold);
|
||||
});
|
||||
|
||||
response = await Promise.race([apiResponse, timeout]);
|
||||
clearTimeout(timer);
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Options
|
||||
*
|
||||
* @param {Object} options
|
||||
* @returns {*}
|
||||
*/
|
||||
function parseOptions(options) {
|
||||
let limit = options.limit ?? 5;
|
||||
let order = options.order ?? 'createdAt desc';
|
||||
let filter = options.filter ?? '';
|
||||
let page = options.page ?? 1;
|
||||
|
||||
return {
|
||||
limit,
|
||||
order,
|
||||
filter,
|
||||
page
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
module.exports = async function recommendations(options) {
|
||||
const recommendationsEnabled = settingsCache.get('recommendations_enabled');
|
||||
|
||||
if (!recommendationsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
options.hash = options.hash || {};
|
||||
options.data = options.data || {};
|
||||
|
||||
const data = createFrame(options.data);
|
||||
let apiOptions = options.hash;
|
||||
apiOptions = parseOptions(apiOptions);
|
||||
|
||||
try {
|
||||
const response = await fetchRecommendations(apiOptions);
|
||||
|
||||
if (response.recommendations && response.recommendations.length) {
|
||||
response.recommendations.forEach(prepareContextResource);
|
||||
}
|
||||
|
||||
if (response.meta && response.meta.pagination) {
|
||||
response.pagination = response.meta.pagination;
|
||||
}
|
||||
|
||||
return templates.execute('recommendations', response, {data});
|
||||
} catch (error) {
|
||||
logging.error(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports.async = true;
|
15
ghost/core/core/frontend/helpers/tpl/recommendations.hbs
Normal file
15
ghost/core/core/frontend/helpers/tpl/recommendations.hbs
Normal file
@ -0,0 +1,15 @@
|
||||
{{#if recommendations}}
|
||||
<ul class="recommendations">
|
||||
{{#each recommendations as |rec|}}
|
||||
<li class="recommendation">
|
||||
<a href="{{rec.url}}">
|
||||
<img class="recommendation-favicon" src="{{rec.favicon}}" alt="{{rec.title}}">
|
||||
<div class="recommendation-content">
|
||||
<h5 class="recommendation-title">{{rec.title}}</h5>
|
||||
<p class="recommendation-reason">{{rec.reason}}</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
@ -10,7 +10,8 @@ module.exports = {
|
||||
options: [
|
||||
'limit',
|
||||
'order',
|
||||
'page'
|
||||
'page',
|
||||
'filter'
|
||||
],
|
||||
permissions: true,
|
||||
validation: {},
|
||||
|
201
ghost/core/test/unit/frontend/helpers/recommendations.test.js
Normal file
201
ghost/core/test/unit/frontend/helpers/recommendations.test.js
Normal file
@ -0,0 +1,201 @@
|
||||
const should = require('should');
|
||||
const sinon = require('sinon');
|
||||
const models = require('../../../../core/server/models');
|
||||
const api = require('../../../../core/server/api').endpoints;
|
||||
const hbs = require('../../../../core/frontend/services/theme-engine/engine');
|
||||
const configUtils = require('../../../utils/configUtils');
|
||||
const {html} = require('common-tags');
|
||||
const loggingLib = require('@tryghost/logging');
|
||||
const proxy = require('../../../../core/frontend/services/proxy');
|
||||
|
||||
const recommendations = require('../../../../core/frontend/helpers/recommendations');
|
||||
const foreach = require('../../../../core/frontend/helpers/foreach');
|
||||
const {settingsCache} = proxy;
|
||||
|
||||
function trimSpaces(string) {
|
||||
return string.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
describe('{{#recommendations}} helper', function () {
|
||||
let logging;
|
||||
|
||||
before(function () {
|
||||
models.init();
|
||||
|
||||
hbs.express4({
|
||||
partialsDir: [configUtils.config.get('paths').helperTemplates]
|
||||
});
|
||||
|
||||
hbs.cachePartials();
|
||||
|
||||
// The recommendation template expects this helper
|
||||
hbs.registerHelper('foreach', foreach);
|
||||
|
||||
// Stub settings cache
|
||||
sinon.stub(settingsCache, 'get');
|
||||
// @ts-ignore
|
||||
settingsCache.get.withArgs('recommendations_enabled').returns(true);
|
||||
|
||||
// Stub Recommendation Content API
|
||||
const meta = {pagination: {}};
|
||||
sinon.stub(api, 'recommendationsPublic').get(() => {
|
||||
return {
|
||||
browse: sinon.stub().resolves({recommendations: [
|
||||
{title: 'Recommendation 1', url: 'https://recommendations1.com', favicon: 'https://recommendations1.com/favicon.ico', reason: 'Reason 1'},
|
||||
{title: 'Recommendation 2', url: 'https://recommendations2.com', favicon: 'https://recommendations2.com/favicon.ico', reason: 'Reason 2'},
|
||||
{title: 'Recommendation 3', url: 'https://recommendations3.com', favicon: 'https://recommendations3.com/favicon.ico', reason: 'Reason 3'},
|
||||
{title: 'Recommendation 4', url: 'https://recommendations4.com', favicon: 'https://recommendations4.com/favicon.ico', reason: 'Reason 4'},
|
||||
{title: 'Recommendation 5', url: 'https://recommendations5.com', favicon: 'https://recommendations5.com/favicon.ico', reason: 'Reason 5'}
|
||||
], meta: meta})
|
||||
};
|
||||
});
|
||||
|
||||
// Stub logging
|
||||
logging = {
|
||||
error: sinon.stub(loggingLib, 'error'),
|
||||
warn: sinon.stub(loggingLib, 'warn')
|
||||
};
|
||||
});
|
||||
|
||||
after(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('renders a template with recommendations', async function () {
|
||||
const response = await recommendations.call(
|
||||
'recommendations'
|
||||
);
|
||||
|
||||
response.should.be.an.Object().with.property('string');
|
||||
|
||||
const expected = trimSpaces(html`
|
||||
<ul class="recommendations">
|
||||
<li class="recommendation">
|
||||
<a href="https://recommendations1.com">
|
||||
<img class="recommendation-favicon" src="https://recommendations1.com/favicon.ico" alt="Recommendation 1">
|
||||
<div class="recommendation-content">
|
||||
<h5 class="recommendation-title">Recommendation 1</h5>
|
||||
<p class="recommendation-reason">Reason 1</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="recommendation">
|
||||
<a href="https://recommendations2.com">
|
||||
<img class="recommendation-favicon" src="https://recommendations2.com/favicon.ico" alt="Recommendation 2">
|
||||
<div class="recommendation-content">
|
||||
<h5 class="recommendation-title">Recommendation 2</h5>
|
||||
<p class="recommendation-reason">Reason 2</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="recommendation">
|
||||
<a href="https://recommendations3.com">
|
||||
<img class="recommendation-favicon" src="https://recommendations3.com/favicon.ico" alt="Recommendation 3">
|
||||
<div class="recommendation-content">
|
||||
<h5 class="recommendation-title">Recommendation 3</h5>
|
||||
<p class="recommendation-reason">Reason 3</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="recommendation">
|
||||
<a href="https://recommendations4.com">
|
||||
<img class="recommendation-favicon" src="https://recommendations4.com/favicon.ico" alt="Recommendation 4">
|
||||
<div class="recommendation-content">
|
||||
<h5 class="recommendation-title">Recommendation 4</h5>
|
||||
<p class="recommendation-reason">Reason 4</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="recommendation">
|
||||
<a href="https://recommendations5.com">
|
||||
<img class="recommendation-favicon" src="https://recommendations5.com/favicon.ico" alt="Recommendation 5">
|
||||
<div class="recommendation-content">
|
||||
<h5 class="recommendation-title">Recommendation 5</h5>
|
||||
<p class="recommendation-reason">Reason 5</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
`);
|
||||
const actual = trimSpaces(response.string);
|
||||
|
||||
actual.should.equal(expected);
|
||||
});
|
||||
|
||||
describe('when there are no recommendations', function () {
|
||||
before(function () {
|
||||
sinon.stub(api, 'recommendationsPublic').get(() => {
|
||||
return {
|
||||
browse: () => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({recommendations: []});
|
||||
}, 5);
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing', async function () {
|
||||
const response = await recommendations.call(
|
||||
'recommendations'
|
||||
);
|
||||
|
||||
// No HTML is rendered
|
||||
response.should.be.an.Object().with.property('string');
|
||||
response.string.should.equal('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when recommendations_enabled is false', function () {
|
||||
before(function () {
|
||||
// @ts-ignore
|
||||
settingsCache.get.withArgs('recommendations_enabled').returns(true);
|
||||
});
|
||||
|
||||
it('renders nothing', async function () {
|
||||
const response = await recommendations.call(
|
||||
'recommendations'
|
||||
);
|
||||
|
||||
// No HTML is rendered
|
||||
response.should.be.an.Object().with.property('string');
|
||||
response.string.should.equal('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when timeout is exceeded', function () {
|
||||
before(function () {
|
||||
sinon.stub(api, 'recommendationsPublic').get(() => {
|
||||
return {
|
||||
browse: () => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({recommendations: [{title: 'Recommendation 1', url: 'https://recommendations1.com'}]});
|
||||
}, 5);
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
after(async function () {
|
||||
await configUtils.restore();
|
||||
});
|
||||
|
||||
it('should log an error and return safely if it hits the timeout threshold', async function () {
|
||||
configUtils.set('optimization:getHelper:timeout:threshold', 1);
|
||||
|
||||
const response = await recommendations.call(
|
||||
'recommendations'
|
||||
);
|
||||
|
||||
// An error message is logged
|
||||
logging.error.calledOnce.should.be.true();
|
||||
|
||||
// No HTML is rendered
|
||||
response.should.be.an.Object().with.property('string');
|
||||
response.string.should.equal('');
|
||||
});
|
||||
});
|
||||
});
|
@ -11,7 +11,7 @@ describe('Helpers', function () {
|
||||
'asset', 'authors', 'body_class', 'cancel_link', 'concat', 'content', 'date', 'encode', 'excerpt', 'facebook_url', 'foreach', 'get',
|
||||
'ghost_foot', 'ghost_head', 'has', 'img_url', 'is', 'link', 'link_class', 'meta_description', 'meta_title', 'navigation',
|
||||
'next_post', 'page_url', 'pagination', 'plural', 'post_class', 'prev_post', 'price', 'raw', 'reading_time', 't', 'tags', 'title','total_members', 'total_paid_members', 'twitter_url',
|
||||
'url', 'comment_count', 'collection'
|
||||
'url', 'comment_count', 'collection', 'recommendations'
|
||||
];
|
||||
const experimentalHelpers = ['match', 'tiers', 'comments', 'search'];
|
||||
|
||||
|
@ -84,10 +84,13 @@ export class RecommendationController {
|
||||
const include = options.optionalKey('withRelated')?.array.map(item => item.enum<RecommendationIncludeFields>(['count.clicks', 'count.subscribers'])) ?? [];
|
||||
const filter = options.optionalKey('filter')?.string;
|
||||
|
||||
const orderOption = options.optionalKey('order')?.regex(/^[a-zA-Z]+ (asc|desc)$/) ?? 'createdAt desc';
|
||||
const field = orderOption?.split(' ')[0] as keyof RecommendationPlain;
|
||||
const direction = orderOption?.split(' ')[1] as 'asc'|'desc';
|
||||
const order = [
|
||||
{
|
||||
field: 'createdAt' as const,
|
||||
direction: 'desc' as const
|
||||
field,
|
||||
direction
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -203,4 +203,16 @@ export class UnsafeData {
|
||||
}
|
||||
return arr[index];
|
||||
}
|
||||
|
||||
regex(re: RegExp) : string {
|
||||
if (typeof this.data !== 'string') {
|
||||
throw new errors.ValidationError({message: `${this.field} must be a string`});
|
||||
}
|
||||
|
||||
if (!re.test(this.data)) {
|
||||
throw new errors.ValidationError({message: `${this.field} must follow the format of "createdAt desc"`});
|
||||
}
|
||||
|
||||
return this.data;
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user