diff --git a/README.md b/README.md index 65f49be..3af09aa 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,32 @@ If you prefer using `https` links you'd replace `git@github.com:adobe/aem-experi ## Project instrumentation +### On top of the plugin system + +The easiest way to add the plugin is if your project is set up with the plugin system extension in the boilerplate. +You'll know you have it if `window.hlx.plugins` is defined on your page. + +If you don't have it, you can follow the proposal in https://github.com/adobe/aem-lib/pull/23 and apply the changes to your `aem.js`/`lib-franklin.js`. + +Once you have confirmed this, you'll need to edit your `scripts.js` in your AEM project and add the following at the start of the file: +```js +const AUDIENCES = { + mobile: () => window.innerWidth < 600, + desktop: () => window.innerWidth >= 600, + // define your custom audiences here as needed +}; + +window.hlx.plugins.add('experience-decisioning', { + condition: () => getMetadata('experiment') + || Object.keys(getAllMetadata('campaign')).length + || Object.keys(getAllMetadata('audience')).length, + options: { audiences: AUDIENCES }, + url: '/plugins/experience-decisioning/src/index.js', +}); +``` + +### Without the plugin system + To properly connect and configure the plugin for your project, you'll need to edit your `scripts.js` in your AEM project and add the following: 1. at the start of the file: @@ -77,7 +103,7 @@ To properly connect and configure the plugin for your project, you'll need to ed || Object.keys(getAllMetadata('audience')).length) { // eslint-disable-next-line import/no-relative-packages const { loadEager: runEager } = await import('../plugins/experience-decisioning/src/index.js'); - await runEager.call(pluginContext, { audiences: AUDIENCES }); + await runEager(document, { audiences: AUDIENCES }, pluginContext); } … } @@ -90,11 +116,10 @@ To properly connect and configure the plugin for your project, you'll need to ed // Add below snippet at the end of the lazy phase if ((getMetadata('experiment') || Object.keys(getAllMetadata('campaign')).length - || Object.keys(getAllMetadata('audience')).length) - && (window.location.hostname.endsWith('hlx.page') || window.location.hostname === ('localhost'))) { + || Object.keys(getAllMetadata('audience')).length)) { // eslint-disable-next-line import/no-relative-packages const { loadLazy: runLazy } = await import('../plugins/experience-decisioning/src/index.js'); - await runLazy.call(pluginContext, { audiences: AUDIENCES }); + await runLazy(document, { audiences: AUDIENCES }, pluginContext); } } ``` @@ -109,6 +134,10 @@ You have already seen the `audiences` option in the examples above, but here is runEager.call(pluginContext, { // Overrides the base path if the plugin was installed in a sub-directory basePath: '', + // Lets you configure if we are in a prod environment or not + // (prod environments do not get the pill overlay) + isProd: () => window.location.hostname.endsWith('hlx.page') + || window.location.hostname === ('localhost') /* Generic properties */ // RUM sampling rate on regular AEM pages is 1 out of 100 page views diff --git a/src/index.js b/src/index.js index 7a6f487..22142b7 100644 --- a/src/index.js +++ b/src/index.js @@ -45,7 +45,7 @@ function isBot() { * @param {object} options the plugin options * @returns Returns the names of the resolved audiences, or `null` if no audience is configured */ -export async function getResolvedAudiences(applicableAudiences, options) { +export async function getResolvedAudiences(applicableAudiences, options, context) { if (!applicableAudiences.length || !Object.keys(options.audiences).length) { return null; } @@ -53,7 +53,7 @@ export async function getResolvedAudiences(applicableAudiences, options) { // we check if it is applicable const usp = new URLSearchParams(window.location.search); const forcedAudience = usp.has(options.audiencesQueryParameter) - ? this.toClassName(usp.get(options.audiencesQueryParameter)) + ? context.toClassName(usp.get(options.audiencesQueryParameter)) : null; if (forcedAudience) { return applicableAudiences.includes(forcedAudience) ? [forcedAudience] : []; @@ -121,11 +121,11 @@ async function replaceInner(path, element) { * } * }; */ -function parseExperimentConfig(json) { +function parseExperimentConfig(json, context) { const config = {}; try { json.settings.data.forEach((line) => { - const key = this.toCamelCase(line.Name); + const key = context.toCamelCase(line.Name); if (key === 'audience' || key === 'audiences') { config.audiences = line.Value ? line.Value.split(',').map((str) => str.trim()) : []; } else if (key === 'experimentName') { @@ -137,19 +137,19 @@ function parseExperimentConfig(json) { const variants = {}; let variantNames = Object.keys(json.experiences.data[0]); variantNames.shift(); - variantNames = variantNames.map((vn) => this.toCamelCase(vn)); + variantNames = variantNames.map((vn) => context.toCamelCase(vn)); variantNames.forEach((variantName) => { variants[variantName] = {}; }); let lastKey = 'default'; json.experiences.data.forEach((line) => { - let key = this.toCamelCase(line.Name); + let key = context.toCamelCase(line.Name); if (!key) key = lastKey; lastKey = key; const vns = Object.keys(line); vns.shift(); vns.forEach((vn) => { - const camelVN = this.toCamelCase(vn); + const camelVN = context.toCamelCase(vn); if (key === 'pages' || key === 'blocks') { variants[camelVN][key] = variants[camelVN][key] || []; if (key === 'pages') variants[camelVN][key].push(new URL(line[vn]).pathname); @@ -223,11 +223,16 @@ function inferEmptyPercentageSplits(variants) { * @param {string} instantExperiment The list of varaints * @returns {object} the experiment manifest */ -export function getConfigForInstantExperiment(experimentId, instantExperiment, pluginOptions) { - const audience = this.getMetadata(`${pluginOptions.experimentsMetaTag}-audience`); +export function getConfigForInstantExperiment( + experimentId, + instantExperiment, + pluginOptions, + context, +) { + const audience = context.getMetadata(`${pluginOptions.experimentsMetaTag}-audience`); const config = { label: `Instant Experiment: ${experimentId}`, - audiences: audience ? audience.split(',').map(this.toClassName) : [], + audiences: audience ? audience.split(',').map(context.toClassName) : [], status: 'Active', id: experimentId, variants: {}, @@ -236,7 +241,7 @@ export function getConfigForInstantExperiment(experimentId, instantExperiment, p const pages = instantExperiment.split(',').map((p) => new URL(p.trim()).pathname); - const splitString = this.getMetadata(`${pluginOptions.experimentsMetaTag}-split`); + const splitString = context.getMetadata(`${pluginOptions.experimentsMetaTag}-split`); const splits = splitString // custom split ? splitString.split(',').map((i) => parseInt(i, 10) / 100) @@ -282,7 +287,7 @@ export function getConfigForInstantExperiment(experimentId, instantExperiment, p * @param {object} pluginOptions The plugin options * @returns {object} containing the experiment manifest */ -export async function getConfigForFullExperiment(experimentId, pluginOptions) { +export async function getConfigForFullExperiment(experimentId, pluginOptions, context) { const path = `${pluginOptions.experimentsRoot}/${experimentId}/${pluginOptions.experimentsConfigFile}`; try { const resp = await fetch(path); @@ -293,8 +298,8 @@ export async function getConfigForFullExperiment(experimentId, pluginOptions) { } const json = await resp.json(); const config = pluginOptions.parser - ? pluginOptions.parser.call(this, json) - : parseExperimentConfig.call(this, json); + ? pluginOptions.parser(json, context) + : parseExperimentConfig(json, context); if (!config) { return null; } @@ -331,15 +336,15 @@ function getDecisionPolicy(config) { return decisionPolicy; } -export async function getConfig(experiment, instantExperiment, pluginOptions) { +export async function getConfig(experiment, instantExperiment, pluginOptions, context) { const usp = new URLSearchParams(window.location.search); const [forcedExperiment, forcedVariant] = usp.has(pluginOptions.experimentsQueryParameter) ? usp.get(pluginOptions.experimentsQueryParameter).split('/') : []; const experimentConfig = instantExperiment - ? await getConfigForInstantExperiment.call(this, experiment, instantExperiment, pluginOptions) - : await getConfigForFullExperiment.call(this, experiment, pluginOptions); + ? await getConfigForInstantExperiment(experiment, instantExperiment, pluginOptions, context) + : await getConfigForFullExperiment(experiment, pluginOptions, context); // eslint-disable-next-line no-console console.debug(experimentConfig); @@ -348,17 +353,17 @@ export async function getConfig(experiment, instantExperiment, pluginOptions) { } const forcedAudience = usp.has(pluginOptions.audiencesQueryParameter) - ? this.toClassName(usp.get(pluginOptions.audiencesQueryParameter)) + ? context.toClassName(usp.get(pluginOptions.audiencesQueryParameter)) : null; - experimentConfig.resolvedAudiences = await getResolvedAudiences.call( - this, + experimentConfig.resolvedAudiences = await getResolvedAudiences( experimentConfig.audiences, pluginOptions, + context, ); experimentConfig.run = ( // experiment is active or forced - (this.toCamelCase(experimentConfig.status) === 'active' || forcedExperiment) + (context.toCamelCase(experimentConfig.status) === 'active' || forcedExperiment) // experiment has resolved audiences if configured && (!experimentConfig.resolvedAudiences || experimentConfig.resolvedAudiences.length) // forced audience resolves if defined @@ -384,21 +389,21 @@ export async function getConfig(experiment, instantExperiment, pluginOptions) { return experimentConfig; } -export async function runExperiment(customOptions = {}) { +export async function runExperiment(document, options, context) { if (isBot()) { return false; } - const pluginOptions = { ...DEFAULT_OPTIONS, ...customOptions }; - const experiment = this.getMetadata(pluginOptions.experimentsMetaTag); + const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; + const experiment = context.getMetadata(pluginOptions.experimentsMetaTag); if (!experiment) { return false; } - const variants = this.getMetadata('instant-experiment') - || this.getMetadata(`${pluginOptions.experimentsMetaTag}-variants`); + const variants = context.getMetadata('instant-experiment') + || context.getMetadata(`${pluginOptions.experimentsMetaTag}-variants`); let experimentConfig; try { - experimentConfig = await getConfig.call(this, experiment, variants, pluginOptions); + experimentConfig = await getConfig(experiment, variants, pluginOptions, context); } catch (err) { // eslint-disable-next-line no-console console.error('Invalid experiment config.', err); @@ -435,38 +440,38 @@ export async function runExperiment(customOptions = {}) { console.debug(`failed to serve variant ${window.hlx.experiment.selectedVariant}. Falling back to ${experimentConfig.variantNames[0]}.`); } document.body.classList.add(`variant-${result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0]}`); - this.sampleRUM('experiment', { + context.sampleRUM('experiment', { source: experimentConfig.id, target: result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0], }); return result; } -export async function runCampaign(customOptions) { +export async function runCampaign(document, options, context) { if (isBot()) { return false; } - const options = { ...DEFAULT_OPTIONS, ...customOptions }; + const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; const usp = new URLSearchParams(window.location.search); - const campaign = (usp.has(options.campaignsQueryParameter) - ? this.toClassName(usp.get(options.campaignsQueryParameter)) + const campaign = (usp.has(pluginOptions.campaignsQueryParameter) + ? context.toClassName(usp.get(pluginOptions.campaignsQueryParameter)) : null) - || (usp.has('utm_campaign') ? this.toClassName(usp.get('utm_campaign')) : null); + || (usp.has('utm_campaign') ? context.toClassName(usp.get('utm_campaign')) : null); if (!campaign) { return false; } - let audiences = this.getMetadata(`${options.campaignsMetaTagPrefix}-audience`); + let audiences = context.getMetadata(`${pluginOptions.campaignsMetaTagPrefix}-audience`); if (audiences) { - audiences = audiences.split(',').map(this.toClassName); - const resolvedAudiences = await getResolvedAudiences.call(this, audiences, options); + audiences = audiences.split(',').map(context.toClassName); + const resolvedAudiences = await getResolvedAudiences(audiences, pluginOptions, context); if (!!resolvedAudiences && !resolvedAudiences.length) { return false; } } - const allowedCampaigns = this.getAllMetadata(options.campaignsMetaTagPrefix); + const allowedCampaigns = context.getAllMetadata(pluginOptions.campaignsMetaTagPrefix); if (!Object.keys(allowedCampaigns).includes(campaign)) { return false; } @@ -484,7 +489,7 @@ export async function runCampaign(customOptions) { console.debug(`failed to serve campaign ${campaign}. Falling back to default content.`); } document.body.classList.add(`campaign-${campaign}`); - this.sampleRUM('campaign', { + context.sampleRUM('campaign', { source: window.location.href, target: result ? campaign : 'default', }); @@ -496,21 +501,21 @@ export async function runCampaign(customOptions) { } } -export async function serveAudience(customOptions) { +export async function serveAudience(document, options, context) { if (isBot()) { return false; } - const pluginOptions = { ...DEFAULT_OPTIONS, ...customOptions }; - const configuredAudiences = this.getAllMetadata(pluginOptions.audiencesMetaTagPrefix); + const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; + const configuredAudiences = context.getAllMetadata(pluginOptions.audiencesMetaTagPrefix); if (!Object.keys(configuredAudiences).length) { return false; } - const audiences = await getResolvedAudiences.call( - this, + const audiences = await getResolvedAudiences( Object.keys(configuredAudiences), pluginOptions, + context, ); if (!audiences || !audiences.length) { return false; @@ -518,7 +523,7 @@ export async function serveAudience(customOptions) { const usp = new URLSearchParams(window.location.search); const forcedAudience = usp.has(pluginOptions.audiencesQueryParameter) - ? this.toClassName(usp.get(pluginOptions.audiencesQueryParameter)) + ? context.toClassName(usp.get(pluginOptions.audiencesQueryParameter)) : null; const urlString = configuredAudiences[forcedAudience || audiences[0]]; @@ -534,7 +539,7 @@ export async function serveAudience(customOptions) { console.debug(`failed to serve audience ${forcedAudience || audiences[0]}. Falling back to default content.`); } document.body.classList.add(audiences.map((audience) => `audience-${audience}`)); - this.sampleRUM('audiences', { + context.sampleRUM('audiences', { source: window.location.href, target: result ? forcedAudience || audiences.join(',') : 'default', }); @@ -608,8 +613,8 @@ window.hlx.patchBlockConfig.push((config) => { }); let isAdjusted = false; -function adjustedRumSamplingRate(checkpoint, customOptions) { - const pluginOptions = { ...DEFAULT_OPTIONS, ...customOptions }; +function adjustedRumSamplingRate(checkpoint, options, context) { + const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; return (data) => { if (!window.hlx.rum.isSelected && !isAdjusted) { isAdjusted = true; @@ -622,32 +627,36 @@ function adjustedRumSamplingRate(checkpoint, customOptions) { ); window.hlx.rum.isSelected = (window.hlx.rum.random * window.hlx.rum.weight < 1); if (window.hlx.rum.isSelected) { - this.sampleRUM(checkpoint, data); + context.sampleRUM(checkpoint, data); } } return true; }; } -export async function loadEager(customOptions = {}) { - this.sampleRUM.always.on('audiences', adjustedRumSamplingRate('audiences', customOptions)); - this.sampleRUM.always.on('campaign', adjustedRumSamplingRate('campaign', customOptions)); - this.sampleRUM.always.on('experiment', adjustedRumSamplingRate('experiment', customOptions)); - let res = await runCampaign.call(this, customOptions); +export async function loadEager(document, options, context) { + context.sampleRUM.always.on('audiences', adjustedRumSamplingRate('audiences', options, context)); + context.sampleRUM.always.on('campaign', adjustedRumSamplingRate('campaign', options, context)); + context.sampleRUM.always.on('experiment', adjustedRumSamplingRate('experiment', options, context)); + let res = await runCampaign(document, options, context); if (!res) { - res = await runExperiment.call(this, customOptions); + res = await runExperiment(document, options, context); } if (!res) { - res = await serveAudience.call(this, customOptions); + res = await serveAudience(document, options, context); } } -export async function loadLazy(customOptions = {}) { +export async function loadLazy(document, options, context) { const pluginOptions = { ...DEFAULT_OPTIONS, - ...customOptions, + ...(options || {}), }; - // eslint-disable-next-line import/no-cycle - const preview = await import('./preview.js'); - preview.default.call({ ...this, getResolvedAudiences }, pluginOptions); + if (window.location.hostname.endsWith('hlx.page') + || window.location.hostname === ('localhost') + || (typeof options.isProd === 'function' && !options.isProd())) { + // eslint-disable-next-line import/no-cycle + const preview = await import('./preview.js'); + preview.default(document, pluginOptions, { ...context, getResolvedAudiences }); + } } diff --git a/src/preview.js b/src/preview.js index c671f2c..5401bb8 100644 --- a/src/preview.js +++ b/src/preview.js @@ -273,9 +273,9 @@ function populatePerformanceMetrics(div, config, { * Create Badge if a Page is enlisted in a AEM Experiment * @return {Object} returns a badge or empty string */ -async function decorateExperimentPill(overlay, options) { +async function decorateExperimentPill(overlay, options, context) { const config = window?.hlx?.experiment; - const experiment = this.toClassName(this.getMetadata(options.experimentsMetaTag)); + const experiment = context.toClassName(context.getMetadata(options.experimentsMetaTag)); if (!experiment || !config) { return; } @@ -300,7 +300,7 @@ async function decorateExperimentPill(overlay, options) { }, config.variantNames.map((vname) => createVariant(experiment, vname, config, options)), ); - pill.classList.add(`is-${this.toClassName(config.status)}`); + pill.classList.add(`is-${context.toClassName(config.status)}`); overlay.append(pill); const performanceMetrics = await fetchRumData(experiment, options); @@ -329,25 +329,25 @@ function createCampaign(campaign, isSelected, options) { * Create Badge if a Page is enlisted in a AEM Campaign * @return {Object} returns a badge or empty string */ -async function decorateCampaignPill(overlay, options) { - const campaigns = this.getAllMetadata(options.campaignsMetaTagPrefix); +async function decorateCampaignPill(overlay, options, context) { + const campaigns = context.getAllMetadata(options.campaignsMetaTagPrefix); if (!Object.keys(campaigns).length) { return; } const usp = new URLSearchParams(window.location.search); const forcedAudience = usp.has(options.audiencesQueryParameter) - ? this.toClassName(usp.get(options.audiencesQueryParameter)) + ? context.toClassName(usp.get(options.audiencesQueryParameter)) : null; - const audiences = campaigns.audience?.split(',').map(this.toClassName) || []; - const resolvedAudiences = await this.getResolvedAudiences(audiences, options); + const audiences = campaigns.audience?.split(',').map(context.toClassName) || []; + const resolvedAudiences = await context.getResolvedAudiences(audiences, options); const isActive = forcedAudience ? audiences.includes(forcedAudience) : (!resolvedAudiences || !!resolvedAudiences.length); const campaign = (usp.has(options.campaignsQueryParameter) - ? this.toClassName(usp.get(options.campaignsQueryParameter)) + ? context.toClassName(usp.get(options.campaignsQueryParameter)) : null) - || (usp.has('utm_campaign') ? this.toClassName(usp.get('utm_campaign')) : null); + || (usp.has('utm_campaign') ? context.toClassName(usp.get('utm_campaign')) : null); const pill = createPopupButton( `Campaign: ${campaign || 'default'}`, { @@ -363,7 +363,7 @@ async function decorateCampaignPill(overlay, options) { createCampaign('default', !campaign || !isActive, options), ...Object.keys(campaigns) .filter((c) => c !== 'audience') - .map((c) => createCampaign(c, isActive && this.toClassName(campaign) === c, options)), + .map((c) => createCampaign(c, isActive && context.toClassName(campaign) === c, options)), ], ); @@ -388,15 +388,16 @@ function createAudience(audience, isSelected, options) { * Create Badge if a Page is enlisted in a AEM Audiences * @return {Object} returns a badge or empty string */ -async function decorateAudiencesPill(overlay, options) { - const audiences = this.getAllMetadata(options.audiencesMetaTagPrefix); +async function decorateAudiencesPill(overlay, options, context) { + const audiences = context.getAllMetadata(options.audiencesMetaTagPrefix); if (!Object.keys(audiences).length || !Object.keys(options.audiences).length) { return; } - const resolvedAudiences = await this.getResolvedAudiences( + const resolvedAudiences = await context.getResolvedAudiences( Object.keys(audiences), options, + context, ); const pill = createPopupButton( 'Audiences', @@ -421,13 +422,13 @@ async function decorateAudiencesPill(overlay, options) { * Decorates Preview mode badges and overlays * @return {Object} returns a badge or empty string */ -export default async function decoratePreviewMode(options) { +export default async function decoratePreviewMode(document, options, context) { try { - this.loadCSS(`${options.basePath || window.hlx.codeBasePath}/plugins/experience-decisioning/src/preview.css`); + context.loadCSS(`${options.basePath || window.hlx.codeBasePath}/plugins/experience-decisioning/src/preview.css`); const overlay = getOverlay(options); - await decorateAudiencesPill.call(this, overlay, options); - await decorateCampaignPill.call(this, overlay, options); - await decorateExperimentPill.call(this, overlay, options); + await decorateAudiencesPill(overlay, options, context); + await decorateCampaignPill(overlay, options, context); + await decorateExperimentPill(overlay, options, context); } catch (e) { // eslint-disable-next-line no-console console.log(e);