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:
Sag 2023-09-26 17:15:17 +02:00 committed by GitHub
parent 3a5c233122
commit d24c7c5fa6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 352 additions and 4 deletions

View 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;

View 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}}

View File

@ -10,7 +10,8 @@ module.exports = {
options: [
'limit',
'order',
'page'
'page',
'filter'
],
permissions: true,
validation: {},

View 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('');
});
});
});

View File

@ -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'];

View File

@ -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
}
];

View File

@ -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;
}
};