0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-06 22:40:14 -05:00

Added "exclude" option for customizing {{ghost_head}} (#21229)

no ref

{{ghost_head}} is huge, and some power-users and theme creators want the
ability to customize what it contains. This PR makes it easier for a
theme to write custom schema, or to load a custom version of
portal/comments/search/etc, or to minimize load times by not loading
scripts where they aren't needed, in a theme-specific way.

Because ghost_head is controlled at the theme level, this gives folks in
managed hosting the new ability to load a different version of the
included app scripts (by preventing ghost_head from writing them and
adding them in manually).

Usage example: ` {{ghost_head exclude="search,portal"}} `

(empty array)
	No changes to current behavior

search
	The built-in sodo-search script
Includes adding the click event listener on buttons, generating the
search index, and the UI.

portal
	The portal script
Handles sign-in and sign-up, payments, tips, memberships, etc, and all
the portal data-attributes.

announcement
	The announcement bar javascript
If you'd like to use the announcement bar admin settings but not have it
[mess up your CLS
metric](https://www.spectralwebservices.com/blog/announcement-bar-a-review/),
this is for you.

metadata
Skips HTML tags for meta description, favicon, canonical url, robots,
referrer
	Important for SEO

schema
	The LD+JSON schema
	Important for SEO

card_assets
	Loads cards.min.css and .js
Needed on any page with a post body, unless your theme replaces them
all. Assets can also be selectively loaded with the [card_assets
override](https://ghost.org/docs/themes/content/?ref=spectralwebservices.com#editor-cards)

comment_counts
	Loads the comment_counts helper
Needed if the page is using {{comments}} or data-ghost-comment-count
attribute

social_data
Produces the og: and twitter: attributes for social media sharing and
previews
	Required for good social media cards

cta_styles
	Removes the call to action (CTA) styles
Used for member signup and CTA cards - may be overwritten by your theme
already
This commit is contained in:
Cathy Sarisky 2024-10-31 11:32:34 -04:00 committed by GitHub
parent 7e50a4051f
commit f601ab3fda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1254 additions and 56 deletions

View file

@ -48,28 +48,31 @@ function finaliseStructuredData(meta) {
return head;
}
function getMembersHelper(data, frontendKey) {
function getMembersHelper(data, frontendKey, excludeList) {
// Do not load Portal if both Memberships and Tips & Donations and Recommendations are disabled
if (!settingsCache.get('members_enabled') && !settingsCache.get('donations_enabled') && !settingsCache.get('recommendations_enabled')) {
return '';
}
let membersHelper = '';
if (!excludeList.has('portal')) {
const {scriptUrl} = getFrontendAppConfig('portal');
const {scriptUrl} = getFrontendAppConfig('portal');
const colorString = (_.has(data, 'site._preview') && data.site.accent_color) ? data.site.accent_color : '';
const attributes = {
i18n: labs.isSet('i18n'),
ghost: urlUtils.getSiteUrl(),
key: frontendKey,
api: urlUtils.urlFor('api', {type: 'content'}, true)
};
if (colorString) {
attributes['accent-color'] = colorString;
const colorString = (_.has(data, 'site._preview') && data.site.accent_color) ? data.site.accent_color : '';
const attributes = {
i18n: labs.isSet('i18n'),
ghost: urlUtils.getSiteUrl(),
key: frontendKey,
api: urlUtils.urlFor('api', {type: 'content'}, true)
};
if (colorString) {
attributes['accent-color'] = colorString;
}
const dataAttributes = getDataAttributes(attributes);
membersHelper += `<script defer src="${scriptUrl}" ${dataAttributes} crossorigin="anonymous"></script>`;
}
if (!excludeList.has('cta_styles')) {
membersHelper += (`<style id="gh-members-styles">${templateStyles}</style>`);
}
const dataAttributes = getDataAttributes(attributes);
let membersHelper = `<script defer src="${scriptUrl}" ${dataAttributes} crossorigin="anonymous"></script>`;
membersHelper += (`<style id="gh-members-styles">${templateStyles}</style>`);
if (settingsCache.get('paid_members_enabled')) {
// disable fraud detection for e2e tests to reduce waiting time
const isFraudSignalsEnabled = process.env.NODE_ENV === 'testing-browser' ? '?advancedFraudSignals=false' : '';
@ -198,12 +201,11 @@ function getTinybirdTrackerScript(dataRoot) {
// We use the name ghost_head to match the helper for consistency:
module.exports = async function ghost_head(options) { // eslint-disable-line camelcase
debug('begin');
// if server error page do nothing
if (options.data.root.statusCode >= 500) {
return;
}
const excludeList = new Set(options?.hash?.exclude?.split(',') || []);
const head = [];
const dataRoot = options.data.root;
const context = dataRoot._locals.context ? dataRoot._locals.context : null;
@ -234,25 +236,26 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam
debug('end fetch');
if (context) {
// head is our main array that holds our meta data
if (meta.metaDescription && meta.metaDescription.length > 0) {
head.push('<meta name="description" content="' + escapeExpression(meta.metaDescription) + '">');
if (!excludeList.has('metadata')) {
// head is our main array that holds our meta data
if (meta.metaDescription && meta.metaDescription.length > 0) {
head.push('<meta name="description" content="' + escapeExpression(meta.metaDescription) + '">');
}
// no output in head if a publication icon is not set
if (settingsCache.get('icon')) {
head.push('<link rel="icon" href="' + favicon + '" type="image/' + iconType + '">');
}
head.push('<link rel="canonical" href="' + escapeExpression(meta.canonicalUrl) + '">');
if (_.includes(context, 'preview')) {
head.push(writeMetaTag('robots', 'noindex,nofollow', 'name'));
head.push(writeMetaTag('referrer', 'same-origin', 'name'));
} else {
head.push(writeMetaTag('referrer', referrerPolicy, 'name'));
}
}
// no output in head if a publication icon is not set
if (settingsCache.get('icon')) {
head.push('<link rel="icon" href="' + favicon + '" type="image/' + iconType + '">');
}
head.push('<link rel="canonical" href="' + escapeExpression(meta.canonicalUrl) + '">');
if (_.includes(context, 'preview')) {
head.push(writeMetaTag('robots', 'noindex,nofollow', 'name'));
head.push(writeMetaTag('referrer', 'same-origin', 'name'));
} else {
head.push(writeMetaTag('referrer', referrerPolicy, 'name'));
}
// show amp link in post when 1. we are not on the amp page and 2. amp is enabled
if (_.includes(context, 'post') && !_.includes(context, 'amp') && settingsCache.get('amp')) {
head.push('<link rel="amphtml" href="' +
@ -270,45 +273,51 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam
}
if (!_.includes(context, 'paged') && useStructuredData) {
head.push('');
head.push.apply(head, finaliseStructuredData(meta));
head.push('');
if (meta.schema) {
if (!excludeList.has('social_data')) {
head.push('');
head.push.apply(head, finaliseStructuredData(meta));
head.push('');
}
if (!excludeList.has('schema') && meta.schema) {
head.push('<script type="application/ld+json">\n' +
JSON.stringify(meta.schema, null, ' ') +
'\n </script>\n');
}
}
}
head.push('<meta name="generator" content="Ghost ' +
escapeExpression(safeVersion) + '">');
head.push('<link rel="alternate" type="application/rss+xml" title="' +
escapeExpression(meta.site.title) + '" href="' +
escapeExpression(meta.rssUrl) + '">');
// no code injection for amp context!!!
if (!_.includes(context, 'amp')) {
head.push(getMembersHelper(options.data, frontendKey));
head.push(getSearchHelper(frontendKey));
head.push(getAnnouncementBarHelper(options.data));
head.push(getMembersHelper(options.data, frontendKey, excludeList)); // controlling for excludes within the function
if (!excludeList.has('search')) {
head.push(getSearchHelper(frontendKey));
}
if (!excludeList.has('announcement')) {
head.push(getAnnouncementBarHelper(options.data));
}
try {
head.push(getWebmentionDiscoveryLink());
} catch (err) {
logging.warn(err);
}
// @TODO do this in a more "frameworky" way
if (cardAssets.hasFile('js')) {
head.push(`<script defer src="${getAssetUrl('public/cards.min.js')}"></script>`);
}
if (cardAssets.hasFile('css')) {
head.push(`<link rel="stylesheet" type="text/css" href="${getAssetUrl('public/cards.min.css')}">`);
if (!excludeList.has('card_assets')) {
if (cardAssets.hasFile('js')) {
head.push(`<script defer src="${getAssetUrl('public/cards.min.js')}"></script>`);
}
if (cardAssets.hasFile('css')) {
head.push(`<link rel="stylesheet" type="text/css" href="${getAssetUrl('public/cards.min.css')}">`);
}
}
if (settingsCache.get('comments_enabled') !== 'off') {
if (!excludeList.has('comment_counts') && settingsCache.get('comments_enabled') !== 'off') {
head.push(`<script defer src="${getAssetUrl('public/comment-counts.min.js')}" data-ghost-comments-counts-api="${urlUtils.getSiteUrl(true)}members/api/comments/counts/"></script>`);
}
@ -327,7 +336,6 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam
head.push(styleTag);
}
}
if (!_.isEmpty(globalCodeinjection)) {
head.push(globalCodeinjection);
}
@ -339,7 +347,7 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam
if (!_.isEmpty(tagCodeInjection)) {
head.push(tagCodeInjection);
}
if (config.get('tinybird') && config.get('tinybird:tracker') && config.get('tinybird:tracker:scriptUrl')) {
head.push(getTinybirdTrackerScript(dataRoot));
}

View file

@ -10,6 +10,7 @@ const models = require('../../../../core/server/models');
const imageLib = require('../../../../core/server/lib/image');
const routing = require('../../../../core/frontend/services/routing');
const urlService = require('../../../../core/server/services/url');
const {cardAssets} = require('../../../../core/frontend/services/assets-minification');
const logging = require('@tryghost/logging');
const ghost_head = require('../../../../core/frontend/helpers/ghost_head');
@ -1466,4 +1467,187 @@ describe('{{ghost_head}} helper', function () {
}));
});
});
describe('respects values from excludes: ', function () {
it('when excludes is empty', async function () {
settingsCache.get.withArgs('members_enabled').returns(true);
settingsCache.get.withArgs('paid_members_enabled').returns(true);
let rendered = await testGhostHead({hash: {exclude: ''}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '4.3'
}
})});
rendered.should.match(/portal@/);
rendered.should.match(/sodo-search@/);
rendered.should.match(/js.stripe.com/);
});
it('when exclude contains search', async function () {
settingsCache.get.withArgs('members_enabled').returns(true);
settingsCache.get.withArgs('paid_members_enabled').returns(true);
let rendered = await testGhostHead({hash: {exclude: 'search'}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '4.3'
}
})});
rendered.should.not.match(/sodo-search@/);
rendered.should.match(/portal@/);
rendered.should.match(/js.stripe.com/);
});
it('when exclude contains portal', async function () {
settingsCache.get.withArgs('members_enabled').returns(true);
settingsCache.get.withArgs('paid_members_enabled').returns(true);
let rendered = await testGhostHead({hash: {exclude: 'portal'}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '4.3'
}
})});
rendered.should.match(/sodo-search@/);
rendered.should.not.match(/portal@/);
rendered.should.match(/js.stripe.com/);
});
it('can handle multiple excludes', async function () {
settingsCache.get.withArgs('members_enabled').returns(true);
settingsCache.get.withArgs('paid_members_enabled').returns(true);
let rendered = await testGhostHead({hash: {exclude: 'portal,search'}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '4.3'
}
})});
rendered.should.not.match(/sodo-search@/);
rendered.should.not.match(/portal@/);
rendered.should.match(/js.stripe.com/);
});
it('shows the announcement when exclude does not contain announcement', async function () {
settingsCache.get.withArgs('members_enabled').returns(true);
settingsCache.get.withArgs('paid_members_enabled').returns(true);
settingsCache.get.withArgs('announcement_content').returns('Hello world');
settingsCache.get.withArgs('announcement_visibility').returns('visitors');
let rendered = await testGhostHead({hash: {exclude: ''}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '4.3'
}
})});
rendered.should.match(/sodo-search@/);
rendered.should.match(/portal@/);
rendered.should.match(/js.stripe.com/);
rendered.should.match(/announcement-bar@/);
});
it('does not show the announcement when exclude contains announcement', async function () {
settingsCache.get.withArgs('members_enabled').returns(true);
settingsCache.get.withArgs('paid_members_enabled').returns(true);
settingsCache.get.withArgs('announcement_content').returns('Hello world');
settingsCache.get.withArgs('announcement_visibility').returns('visitors');
let rendered = await testGhostHead({hash: {exclude: 'announcement'}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '4.3'
}
})});
rendered.should.match(/sodo-search@/);
rendered.should.match(/portal@/);
rendered.should.match(/js.stripe.com/);
rendered.should.match(/generator/);
rendered.should.not.match(/announcement-bar@/);
});
it('does not load the comments script when exclude contains comment_counts', async function () {
settingsCache.get.withArgs('comments_enabled').returns('all');
let rendered = await testGhostHead({hash: {exclude: 'comment_counts'}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '0.3'
}
})});
rendered.should.not.match(/comment-counts.min.js/);
});
it('loads card assets when not excluded', async function () {
// mock the card assets cardAssets.hasFile('js', 'cards.min.js').returns(true);
sinon.stub(cardAssets, 'hasFile').returns(true);
let rendered = await testGhostHead({...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '0.3'
}
})});
rendered.should.match(/cards.min.js/);
rendered.should.match(/cards.min.css/);
});
it('does not load card assets when excluded with card_assets', async function () {
sinon.stub(cardAssets, 'hasFile').returns(true);
let rendered = await testGhostHead({hash: {exclude: 'card_assets'}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '0.3'
}
})});
rendered.should.not.match(/cards.min.js/);
rendered.should.not.match(/cards.min.css/);
});
it('does not load meta tags when excluded with metadata', async function () {
let rendered = await testGhostHead({hash: {exclude: 'metadata'}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '0.3'
}
})});
rendered.should.not.match(/<link rel="canonical"/);
});
it('does not load schema when excluded with schema', async function () {
let rendered = await testGhostHead({hash: {exclude: 'schema'}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '0.3'
}
})});
rendered.should.not.match(/<script type="application\/ld\+json"/);
});
it('does not load og: or twitter: attributes when excludd with social_data', async function () {
let rendered = await testGhostHead({hash: {exclude: 'social_data'}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '0.3'
}
})});
rendered.should.not.match(/<meta property="og:/);
rendered.should.not.match(/<meta property="twitter:/);
});
it('does not load cta styles when excluded with cta_styles', async function () {
settingsCache.get.withArgs('members_enabled').returns(true);
settingsCache.get.withArgs('paid_members_enabled').returns(true);
let rendered = await testGhostHead({hash: {exclude: 'cta_styles'}, ...testUtils.createHbsResponse({
locals: {
relativeUrl: '/',
context: ['home', 'index'],
safeVersion: '0.3'
}
})});
rendered.should.not.match(/.gh-post-upgrade-cta-content/);
});
});
});