From f601ab3fda9c2e73543ac849fe34ddf1a5b24e6a Mon Sep 17 00:00:00 2001 From: Cathy Sarisky <42299862+cathysarisky@users.noreply.github.com> Date: Thu, 31 Oct 2024 11:32:34 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20"exclude"=20option=20for=20?= =?UTF-8?q?customizing=20{{ghost=5Fhead}}=20(#21229)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../core/core/frontend/helpers/ghost_head.js | 120 +- .../__snapshots__/ghost_head.test.js.snap | 1006 +++++++++++++++++ .../unit/frontend/helpers/ghost_head.test.js | 184 +++ 3 files changed, 1254 insertions(+), 56 deletions(-) diff --git a/ghost/core/core/frontend/helpers/ghost_head.js b/ghost/core/core/frontend/helpers/ghost_head.js index ccf64edd2d..16745877a7 100644 --- a/ghost/core/core/frontend/helpers/ghost_head.js +++ b/ghost/core/core/frontend/helpers/ghost_head.js @@ -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 += ``; + } + if (!excludeList.has('cta_styles')) { + membersHelper += (``); } - const dataAttributes = getDataAttributes(attributes); - - let membersHelper = ``; - membersHelper += (``); 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(''); + if (!excludeList.has('metadata')) { + // head is our main array that holds our meta data + if (meta.metaDescription && meta.metaDescription.length > 0) { + head.push(''); + } + + // no output in head if a publication icon is not set + if (settingsCache.get('icon')) { + head.push(''); + } + + head.push(''); + + 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(''); - } - - head.push(''); - - 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('\n' + JSON.stringify(meta.schema, null, ' ') + '\n \n'); } } } - head.push(''); - head.push(''); - // 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(``); - } - if (cardAssets.hasFile('css')) { - head.push(``); + + if (!excludeList.has('card_assets')) { + if (cardAssets.hasFile('js')) { + head.push(``); + } + if (cardAssets.hasFile('css')) { + head.push(``); + } } - if (settingsCache.get('comments_enabled') !== 'off') { + if (!excludeList.has('comment_counts') && settingsCache.get('comments_enabled') !== 'off') { head.push(``); } @@ -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)); } diff --git a/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap b/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap index b3fc1e298c..3a4938eeee 100644 --- a/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap +++ b/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap @@ -2428,6 +2428,1012 @@ Object { } `; +exports[`{{ghost_head}} helper respects values from excludes: can handle multiple excludes 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load card assets when excluded with card_assets 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load cta styles when excluded with cta_styles 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load meta tags when excluded with metadata 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load og: or twitter: attributes when excludd with social_data 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load schema when excluded with schema 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not load the comments script when exclude contains comment_counts 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: does not show the announcement when exclude contains announcement 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: loads card assets when not excluded 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: shows the announcement when exclude does not contain announcement 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: when exclude contains portal 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: when exclude contains search 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper respects values from excludes: when excludes is empty 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + exports[`{{ghost_head}} helper search scripts includes search when labs flag enabled 1 1`] = ` Object { "rendered": " diff --git a/ghost/core/test/unit/frontend/helpers/ghost_head.test.js b/ghost/core/test/unit/frontend/helpers/ghost_head.test.js index fb88e0d52d..1adf73f2d1 100644 --- a/ghost/core/test/unit/frontend/helpers/ghost_head.test.js +++ b/ghost/core/test/unit/frontend/helpers/ghost_head.test.js @@ -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(/