diff --git a/head.html b/head.html index f2adc67..0163ab6 100644 --- a/head.html +++ b/head.html @@ -1,5 +1,6 @@ + diff --git a/plugins/experimentation/src/index.js b/plugins/experimentation/src/index.js index 0e079ce..8051249 100644 --- a/plugins/experimentation/src/index.js +++ b/plugins/experimentation/src/index.js @@ -1,5 +1,5 @@ /* - * Copyright 2022 Adobe. All rights reserved. + * Copyright 2024 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -9,11 +9,27 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -const MAX_SAMPLING_RATE = 10; // At a maximum we sample 1 in 10 requests + +let isDebugEnabled; +export function setDebugMode(url, pluginOptions) { + const { host, hostname, origin } = url; + const { isProd, prodHost } = pluginOptions; + isDebugEnabled = (url.hostname === 'localhost' + || url.hostname.endsWith('.page') + || (typeof isProd === 'function' && !isProd()) + || (prodHost && ![host, hostname, origin].includes(prodHost)) + || false); + return isDebugEnabled; +} + +export function debug(...args) { + if (isDebugEnabled) { + // eslint-disable-next-line no-console + console.debug.call(this, '[aem-experimentation]', ...args); + } +} export const DEFAULT_OPTIONS = { - // Generic properties - rumSamplingRate: MAX_SAMPLING_RATE, // 1 in 10 requests // Audiences related properties audiences: {}, @@ -25,60 +41,217 @@ export const DEFAULT_OPTIONS = { campaignsQueryParameter: 'campaign', // Experimentation related properties - experimentsRoot: '/experiments', - experimentsConfigFile: 'manifest.json', - experimentsMetaTag: 'experiment', + experimentsMetaTagPrefix: 'experiment', experimentsQueryParameter: 'experiment', + + // Redecoration function for fragments + decorateFunction: () => {}, }; /** - * Checks if the current engine is detected as being a bot. - * @returns `true` if the current engine is detected as being, `false` otherwise + * Converts a given comma-seperate string to an array. + * @param {String|String[]} str The string to convert + * @returns an array representing the converted string */ -function isBot() { - return navigator.userAgent.match(/bot|crawl|spider/i); +export function stringToArray(str) { + if (Array.isArray(str)) { + return str; + } + return str ? str.split(/[,\n]/).filter((s) => s.trim()) : []; } /** - * Checks if any of the configured audiences on the page can be resolved. - * @param {string[]} applicableAudiences a list of configured audiences for the page - * @param {object} options the plugin options - * @returns Returns the names of the resolved audiences, or `null` if no audience is configured + * Sanitizes a name for use as class name. + * @param {String} name The unsanitized name + * @returns {String} The class name */ -export async function getResolvedAudiences(applicableAudiences, options, context) { - if (!applicableAudiences.length || !Object.keys(options.audiences).length) { - return null; +export function toClassName(name) { + return typeof name === 'string' + ? name.toLowerCase().replace(/[^0-9a-z]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') + : ''; +} + +/** + * Triggers the callback when the page is actually activated, + * This is to properly handle speculative page prerendering and marketing events. + * @param {Function} cb The callback to run + */ +async function onPageActivation(cb) { + // Speculative prerender-aware execution. + // See: https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API#unsafe_prerendering + if (document.prerendering) { + document.addEventListener('prerenderingchange', cb, { once: true }); + } else { + cb(); } - // If we have a forced audience set in the query parameters (typically for simulation purposes) - // we check if it is applicable +} + +/** + * Fires a Real User Monitoring (RUM) event based on the provided type and configuration. + * @param {string} type - the type of event to be fired ("experiment", "campaign", or "audience") + * @param {Object} config - contains details about the experience + * @param {Object} pluginOptions - default plugin options with custom options + * @param {string} result - the URL of the served experience. + */ +function fireRUM(type, config, pluginOptions, result) { + const { selectedCampaign = 'default', selectedAudience = 'default' } = config; + + const typeHandlers = { + experiment: () => ({ + source: config.id, + target: result ? config.selectedVariant : config.variantNames[0], + }), + campaign: () => ({ + source: result ? toClassName(selectedCampaign) : 'default', + target: Object.keys(pluginOptions.audiences).join(':'), + }), + audience: () => ({ + source: result ? toClassName(selectedAudience) : 'default', + target: Object.keys(pluginOptions.audiences).join(':'), + }), + }; + + const { source, target } = typeHandlers[type](); + const rumType = type === 'experiment' ? 'experiment' : 'audience'; + onPageActivation(() => { + window.hlx?.rum?.sampleRUM(rumType, { source, target }); + }); +} + +/** + * Sanitizes a name for use as a js property name. + * @param {String} name The unsanitized name + * @returns {String} The camelCased name + */ +export function toCamelCase(name) { + return toClassName(name).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); +} + +/** + * Removes all leading hyphens from a string. + * @param {String} after the string to remove the leading hyphens from, usually is colon + * @returns {String} The string without leading hyphens + */ +export function removeLeadingHyphens(inputString) { + // Remove all leading hyphens which are converted from the space in metadata + return inputString.replace(/^(-+)/, ''); +} + +/** + * Retrieves the content of metadata tags. + * @param {String} name The metadata name (or property) + * @returns {String} The metadata value(s) + */ +export function getMetadata(name) { + const meta = [...document.head.querySelectorAll(`meta[name="${name}"]`)].map((m) => m.content).join(', '); + return meta || ''; +} + +/** + * Gets all the metadata elements that are in the given scope. + * @param {String} scope The scope/prefix for the metadata + * @returns a map of key/value pairs for the given scope + */ +export function getAllMetadata(scope) { + const value = getMetadata(scope); + const metaTags = document.head.querySelectorAll(`meta[name^="${scope}"], meta[property^="${scope}:"]`); + return [...metaTags].reduce((res, meta) => { + const key = removeLeadingHyphens( + meta.getAttribute('name') + ? meta.getAttribute('name').substring(scope.length) + : meta.getAttribute('property').substring(scope.length + 1), + ); + + const camelCaseKey = toCamelCase(key); + res[camelCaseKey] = meta.getAttribute('content'); + return res; + }, value ? { value } : {}); +} + +/** + * Gets all the data attributes that are in the given scope. + * @param {String} scope The scope/prefix for the metadata + * @returns a map of key/value pairs for the given scope + */ +// eslint-disable-next-line no-unused-vars +function getAllDataAttributes(el, scope) { + return el.getAttributeNames() + .filter((attr) => attr === `data-${scope}` || attr.startsWith(`data-${scope}-`)) + .reduce((res, attr) => { + const key = attr === `data-${scope}` ? 'value' : attr.replace(`data-${scope}-`, ''); + res[key] = el.getAttribute(attr); + return res; + }, {}); +} + +/** + * Gets all the query parameters that are in the given scope. + * @param {String} scope The scope/prefix for the metadata + * @returns a map of key/value pairs for the given scope + */ +function getAllQueryParameters(scope) { const usp = new URLSearchParams(window.location.search); - const forcedAudience = usp.has(options.audiencesQueryParameter) - ? context.toClassName(usp.get(options.audiencesQueryParameter)) - : null; - if (forcedAudience) { - return applicableAudiences.includes(forcedAudience) ? [forcedAudience] : []; - } + return [...usp.entries()] + .filter(([param]) => param === scope || param.startsWith(`${scope}-`)) + .reduce((res, [param, value]) => { + const key = param === scope ? 'value' : param.replace(`${scope}-`, ''); + if (res[key]) { + res[key] = [].concat(res[key], value); + } else { + res[key] = value; + } + return res; + }, {}); +} - // Otherwise, return the list of audiences that are resolved on the page - const results = await Promise.all( - applicableAudiences - .map((key) => { - if (options.audiences[key] && typeof options.audiences[key] === 'function') { - return options.audiences[key](); +/** + * Extracts the config from a block that is in the given scope. + * @param {HTMLElement} block The block element + * @returns a map of key/value pairs for the given scope + */ +// eslint-disable-next-line import/prefer-default-export +function getAllSectionMeta(block, scope) { + const config = {}; + block.querySelectorAll(':scope > div').forEach((row) => { + if (row.children) { + const cols = [...row.children]; + if (cols[1]) { + const col = cols[1]; + let key = toClassName(cols[0].textContent); + if (key !== scope && !key.startsWith(`${scope}-`)) { + return; } - return false; - }), - ); - return applicableAudiences.filter((_, i) => results[i]); + key = key === scope ? 'value' : key.replace(`${scope}-`, ''); + let value = ''; + if (col.querySelector('a')) { + const as = [...col.querySelectorAll('a')]; + if (as.length === 1) { + value = as[0].href; + } else { + value = as.map((a) => a.href); + } + } else if (col.querySelector('p')) { + const ps = [...col.querySelectorAll('p')]; + if (ps.length === 1) { + value = ps[0].textContent; + } else { + value = ps.map((p) => p.textContent); + } + } else value = row.children[1].textContent; + config[key] = value; + } + } + }); + return config; } /** * Replaces element with content from path - * @param {string} path - * @param {HTMLElement} main + * @param {String} path + * @param {HTMLElement} el * @return Returns the path that was loaded or null if the loading failed */ -async function replaceInner(path, main) { +async function replaceInner(path, el, selector) { try { const resp = await fetch(path); if (!resp.ok) { @@ -89,9 +262,15 @@ async function replaceInner(path, main) { const html = await resp.text(); // parse with DOMParser to guarantee valid HTML, and no script execution(s) const dom = new DOMParser().parseFromString(html, 'text/html'); - // do not use replaceWith API here since this would replace the main reference - // in scripts.js as well and prevent proper decoration of the sections/blocks - main.innerHTML = dom.querySelector('main').innerHTML; + // eslint-disable-next-line no-param-reassign + let newEl; + if (selector) { + newEl = dom.querySelector(selector); + } + if (!newEl) { + newEl = dom.querySelector(el.tagName === 'MAIN' ? 'main' : 'main > div'); + } + el.innerHTML = newEl.innerHTML; return path; } catch (e) { // eslint-disable-next-line no-console @@ -101,93 +280,36 @@ async function replaceInner(path, main) { } /** - * Parses the experimentation configuration sheet and creates an internal model. - * - * Output model is expected to have the following structure: - * { - * id: , - * label: , - * blocks: , - * audiences: [], - * status: Active | Inactive, - * variantNames: [], - * variants: { - * [variantName]: { - * label: - * percentageSplit: , - * pages: , - * blocks: , - * } - * } - * }; + * Checks if any of the configured audiences on the page can be resolved. + * @param {String[]} pageAudiences a list of configured audiences for the page + * @param {Object} options the plugin options + * @returns Returns the names of the resolved audiences, or `null` if no audience is configured */ -function parseExperimentConfig(json, context) { - const config = {}; - try { - json.settings.data.forEach((line) => { - 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') { - config.label = line.Value; - } else { - config[key] = line.Value; - } - }); - const variants = {}; - let variantNames = Object.keys(json.experiences.data[0]); - variantNames.shift(); - variantNames = variantNames.map((vn) => context.toCamelCase(vn)); - variantNames.forEach((variantName) => { - variants[variantName] = {}; - }); - let lastKey = 'default'; - json.experiences.data.forEach((line) => { - let key = context.toCamelCase(line.Name); - if (!key) key = lastKey; - lastKey = key; - const vns = Object.keys(line); - vns.shift(); - vns.forEach((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); - else variants[camelVN][key].push(line[vn]); - } else { - variants[camelVN][key] = line[vn]; - } - }); - }); - config.variants = variants; - config.variantNames = variantNames; - return config; - } catch (e) { - // eslint-disable-next-line no-console - console.log('error parsing experiment config:', e, json); +export async function getResolvedAudiences(pageAudiences, options) { + if (!pageAudiences.length || !Object.keys(options.audiences).length) { + return null; } - return null; -} - -/** - * Checks if the given config is a valid experimentation configuration. - * @param {object} config the config to check - * @returns `true` if it is valid, `false` otherwise - */ -export function isValidExperimentationConfig(config) { - if (!config.variantNames - || !config.variantNames.length - || !config.variants - || !Object.values(config.variants).length - || !Object.values(config.variants).every((v) => ( - typeof v === 'object' - && !!v.blocks - && !!v.pages - && (v.percentageSplit === '' || !!v.percentageSplit) - ))) { - return false; + // If we have a forced audience set in the query parameters (typically for simulation purposes) + // we check if it is applicable + const usp = new URLSearchParams(window.location.search); + const forcedAudience = usp.has(options.audiencesQueryParameter) + ? toClassName(usp.get(options.audiencesQueryParameter)) + : null; + if (forcedAudience) { + return pageAudiences.includes(forcedAudience) ? [forcedAudience] : []; } - return true; + + // Otherwise, return the list of audiences that are resolved on the page + const results = await Promise.all( + pageAudiences + .map((key) => { + if (options.audiences[key] && typeof options.audiences[key] === 'function') { + return options.audiences[key](); + } + return false; + }), + ); + return pageAudiences.filter((_, i) => results[i]); } /** @@ -217,118 +339,11 @@ function inferEmptyPercentageSplits(variants) { } /** - * Gets experiment config from the metadata. - * - * @param {string} experimentId The experiment identifier - * @param {string} instantExperiment The list of varaints - * @returns {object} the experiment manifest + * Converts the experiment config to a decision policy + * @param {Object} config The experiment config + * @returns a decision policy for the experiment config */ -function getConfigForInstantExperiment( - experimentId, - instantExperiment, - pluginOptions, - context, -) { - const audience = context.getMetadata(`${pluginOptions.experimentsMetaTag}-audience`); - const config = { - label: `Instant Experiment: ${experimentId}`, - audiences: audience ? audience.split(',').map(context.toClassName) : [], - status: context.getMetadata(`${pluginOptions.experimentsMetaTag}-status`) || 'Active', - startDate: context.getMetadata(`${pluginOptions.experimentsMetaTag}-start-date`), - endDate: context.getMetadata(`${pluginOptions.experimentsMetaTag}-end-date`), - id: experimentId, - variants: {}, - variantNames: [], - }; - - const nbOfVariants = Number(instantExperiment); - const pages = Number.isNaN(nbOfVariants) - ? instantExperiment.split(',').map((p) => new URL(p.trim(), window.location).pathname) - : new Array(nbOfVariants).fill(window.location.pathname); - - const splitString = context.getMetadata(`${pluginOptions.experimentsMetaTag}-split`); - const splits = splitString - // custom split - ? splitString.split(',').map((i) => parseFloat(i) / 100) - // even split fallback - : [...new Array(pages.length)].map(() => 1 / (pages.length + 1)); - - config.variantNames.push('control'); - config.variants.control = { - percentageSplit: '', - pages: [window.location.pathname], - blocks: [], - label: 'Control', - }; - - pages.forEach((page, i) => { - const vname = `challenger-${i + 1}`; - config.variantNames.push(vname); - config.variants[vname] = { - percentageSplit: `${splits[i].toFixed(4)}`, - pages: [page], - blocks: [], - label: `Challenger ${i + 1}`, - }; - }); - inferEmptyPercentageSplits(Object.values(config.variants)); - return (config); -} - -/** - * Gets experiment config from the manifest and transforms it to more easily - * consumable structure. - * - * the manifest consists of two sheets "settings" and "experiences", by default - * - * "settings" is applicable to the entire test and contains information - * like "Audience", "Status" or "Blocks". - * - * "experience" hosts the experiences in rows, consisting of: - * a "Percentage Split", "Label" and a set of "Links". - * - * - * @param {string} experimentId The experiment identifier - * @param {object} pluginOptions The plugin options - * @returns {object} containing the experiment manifest - */ -async function getConfigForFullExperiment(experimentId, pluginOptions, context) { - let path; - if (experimentId.includes(`/${pluginOptions.experimentsConfigFile}`)) { - path = new URL(experimentId, window.location.origin).href; - // eslint-disable-next-line no-param-reassign - [experimentId] = path.split('/').splice(-2, 1); - } else { - path = `${pluginOptions.experimentsRoot}/${experimentId}/${pluginOptions.experimentsConfigFile}`; - } - try { - const resp = await fetch(path); - if (!resp.ok) { - // eslint-disable-next-line no-console - console.log('error loading experiment config:', resp); - return null; - } - const json = await resp.json(); - const config = pluginOptions.parser - ? pluginOptions.parser(json, context) - : parseExperimentConfig(json, context); - if (!config) { - return null; - } - config.id = experimentId; - config.manifest = path; - config.basePath = `${pluginOptions.experimentsRoot}/${experimentId}`; - inferEmptyPercentageSplits(Object.values(config.variants)); - config.status = context.getMetadata(`${pluginOptions.experimentsMetaTag}-status`) || config.status; - return config; - } catch (e) { - // eslint-disable-next-line no-console - console.log(`error loading experiment manifest: ${path}`, e); - } - return null; -} - -function getDecisionPolicy(config) { +function toDecisionPolicy(config) { const decisionPolicy = { id: 'content-experimentation-policy', rootDecisionNodeId: 'n1', @@ -349,379 +364,667 @@ function getDecisionPolicy(config) { return decisionPolicy; } -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('/') - : []; +/** + * Creates an instance of a modification handler that will be responsible for applying the desired + * personalized experience. + * + * @param {String} type The type of modifications to apply + * @param {Object} overrides The config overrides + * @param {Function} metadataToConfig a function that will handle the parsing of the metadata + * @param {Function} getExperienceUrl a function that returns the URL to the experience + * @param {Object} pluginOptions the plugin options + * @param {Function} cb the callback to handle the final steps + * @returns the modification handler + */ +function createModificationsHandler( + type, + overrides, + metadataToConfig, + getExperienceUrl, + pluginOptions, + cb, +) { + return async (el, metadata) => { + const config = await metadataToConfig(pluginOptions, metadata, overrides); + if (!config) { + return null; + } + const ns = { config, el }; + const url = await getExperienceUrl(ns.config); + let res; + if (url && new URL(url, window.location.origin).pathname !== window.location.pathname) { + if (toClassName(metadata?.resolution) === 'redirect') { + // Firing RUM event early since redirection will stop the rest of the JS execution + fireRUM(type, config, pluginOptions, url); + window.location.replace(url); + // eslint-disable-next-line consistent-return + return; + } + // eslint-disable-next-line no-await-in-loop + res = await replaceInner(new URL(url, window.location.origin).pathname, el); + } else { + res = url; + } + cb(el.tagName === 'MAIN' ? document.body : ns.el, ns.config, res ? url : null); + if (res) { + ns.servedExperience = url; + } + return ns; + }; +} - const experimentConfig = instantExperiment - ? await getConfigForInstantExperiment(experiment, instantExperiment, pluginOptions, context) - : await getConfigForFullExperiment(experiment, pluginOptions, context); +/** + * Rename plural properties on the object to singular. + * @param {Object} obj The object + * @param {String[]} props The properties to rename. + * @returns the object with plural properties renamed. + */ +function depluralizeProps(obj, props = []) { + props.forEach((prop) => { + if (obj[`${prop}s`]) { + obj[prop] = obj[`${prop}s`]; + delete obj[`${prop}s`]; + } + }); + return obj; +} - // eslint-disable-next-line no-console - console.debug(experimentConfig); - if (!experimentConfig) { - return null; +/** + * Fetch the configuration entries from a JSON manifest. + * @param {String} urlString the URL to load + * @returns the list of entries that apply to the current page + */ +async function getManifestEntriesForCurrentPage(urlString) { + try { + const url = new URL(urlString, window.location.origin); + const response = await fetch(url.pathname); + const json = await response.json(); + return json.data + .map((entry) => Object.keys(entry).reduce((res, k) => { + res[k.toLowerCase()] = entry[k]; + return res; + }, {})) + .filter((entry) => (!entry.page && !entry.pages) + || entry.page === window.location.pathname + || entry.pages === window.location.pathname) + .filter((entry) => entry.selector || entry.selectors) + .filter((entry) => entry.url || entry.urls) + .map((entry) => depluralizeProps(entry, ['page', 'selector', 'url'])); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Cannot apply manifest: ', urlString, err); } + return null; +} - const forcedAudience = usp.has(pluginOptions.audiencesQueryParameter) - ? context.toClassName(usp.get(pluginOptions.audiencesQueryParameter)) - : null; +/** + * Watches the page for injected DOM elements and automatically applies the fragment customizations + */ +function watchMutationsAndApplyFragments( + ns, + scope, + entries, + aggregator, + getExperienceUrl, + pluginOptions, + metadataToConfig, + overrides, + cb, +) { + if (!entries.length) { + return; + } - experimentConfig.resolvedAudiences = await getResolvedAudiences( - experimentConfig.audiences.map(context.toClassName), + new MutationObserver(async (_, observer) => { + // eslint-disable-next-line no-restricted-syntax + for (const entry of entries) { + // eslint-disable-next-line no-await-in-loop + const config = await metadataToConfig(pluginOptions, entry, overrides); + if (!config || entry.isApplied) { + return; + } + const el = scope.querySelector(entry.selector); + if (!el) { + return; + } + entry.isApplied = true; + const fragmentNS = { config, el, type: 'fragment' }; + // eslint-disable-next-line no-await-in-loop + const url = await getExperienceUrl(fragmentNS.config); + let res; + if (url && new URL(url, window.location.origin).pathname !== window.location.pathname) { + // eslint-disable-next-line no-await-in-loop + res = await replaceInner(new URL(url, window.location.origin).pathname, el, entry.selector); + // eslint-disable-next-line no-await-in-loop + await pluginOptions.decorateFunction(el); + } else { + res = url; + } + cb(el.tagName === 'MAIN' ? document.body : fragmentNS.el, fragmentNS.config, res ? url : null); + if (res) { + fragmentNS.servedExperience = url; + } + debug('fragment', ns, fragmentNS); + aggregator.push(fragmentNS); + } + if (entries.every((entry) => entry.isApplied)) { + observer.disconnect(); + } + }).observe(scope, { childList: true, subtree: true }); +} + +/** + * Apply the page modifications for the specified type. + * + * @param {String} ns the type of modifications to do + * @param {String} paramNS the query parameter namespace + * @param {Object} pluginOptions the plugin options + * @param {Function} metadataToConfig a function that will handle the parsing of the metadata + * @param {Function} manifestToConfig a function that will handle the parsing of the manifest + * @param {Function} getExperienceUrl a function that returns the URL to the experience + * @param {Function} cb the callback to handle the final steps + * @returns an object containing the details of the page modifications that where applied + */ +async function applyAllModifications( + type, + paramNS, + pluginOptions, + metadataToConfig, + manifestToConfig, + getExperienceUrl, + cb, +) { + const modificationsHandler = createModificationsHandler( + type, + getAllQueryParameters(paramNS), + metadataToConfig, + getExperienceUrl, pluginOptions, - context, + cb, ); - experimentConfig.run = ( - // experiment is active or forced - (['active', 'on', 'true'].includes(context.toClassName(experimentConfig.status)) || forcedExperiment) - // experiment has resolved audiences if configured - && (!experimentConfig.resolvedAudiences || experimentConfig.resolvedAudiences.length) - // forced audience resolves if defined - && (!forcedAudience || experimentConfig.audiences.includes(forcedAudience)) - && (!experimentConfig.startDate || new Date(experimentConfig.startDate) <= Date.now()) - && (!experimentConfig.endDate || new Date(experimentConfig.endDate) > Date.now()) + + const configs = []; + + // Full-page modifications + const pageMetadata = getAllMetadata(type); + const pageNS = await modificationsHandler( + document.querySelector('main'), + pageMetadata, ); + if (pageNS) { + pageNS.type = 'page'; + configs.push(pageNS); + debug('page', type, pageNS); + } - window.hlx = window.hlx || {}; - window.hlx.experiment = experimentConfig; + // Section-level modifications + let sectionMetadata; + await Promise.all([...document.querySelectorAll('.section-metadata')] + .map(async (sm) => { + sectionMetadata = getAllSectionMeta(sm, type); + const sectionNS = await modificationsHandler( + sm.parentElement, + sectionMetadata, + ); + if (sectionNS) { + sectionNS.type = 'section'; + debug('section', type, sectionNS); + configs.push(sectionNS); + } + })); - // eslint-disable-next-line no-console - console.debug('run', experimentConfig.run, experimentConfig.audiences); - if (forcedVariant && experimentConfig.variantNames.includes(forcedVariant)) { - experimentConfig.selectedVariant = forcedVariant; - } else { - // eslint-disable-next-line import/extensions - const { ued } = await import('./ued.js'); - const decision = ued.evaluateDecisionPolicy(getDecisionPolicy(experimentConfig), {}); - experimentConfig.selectedVariant = decision.items[0].id; + if (pageMetadata.manifest) { + let entries = await getManifestEntriesForCurrentPage(pageMetadata.manifest); + if (entries) { + entries = manifestToConfig(entries); + watchMutationsAndApplyFragments( + type, + document.body, + entries, + configs, + getExperienceUrl, + pluginOptions, + metadataToConfig, + getAllQueryParameters(paramNS), + cb, + ); + } } - return experimentConfig; + + return configs; } -export async function runExperiment(document, options, context) { - if (isBot()) { - return false; - } +function aggregateEntries(type, allowedMultiValuesProperties) { + return (entries) => entries.reduce((aggregator, entry) => { + Object.entries(entry).forEach(([key, value]) => { + if (!aggregator[key]) { + aggregator[key] = value; + } else if (aggregator[key] !== value) { + if (allowedMultiValuesProperties.includes(key)) { + aggregator[key] = [].concat(aggregator[key], value); + } else { + // eslint-disable-next-line no-console + console.warn(`Key "${key}" in the ${type} manifest must be the same for every variant on the page.`); + } + } + }); + return aggregator; + }, {}); +} - const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; - const experiment = context.getMetadata(pluginOptions.experimentsMetaTag); - if (!experiment) { - return false; - } - const variants = context.getMetadata('instant-experiment') - || context.getMetadata(`${pluginOptions.experimentsMetaTag}-variants`); - let experimentConfig; - try { - experimentConfig = await getConfig(experiment, variants, pluginOptions, context); - } catch (err) { - // eslint-disable-next-line no-console - console.error('Invalid experiment config.', err); - } - if (!experimentConfig || !isValidExperimentationConfig(experimentConfig)) { - // eslint-disable-next-line no-console - console.warn('Invalid experiment config. Please review your metadata, sheet and parser.'); - return false; +/** + * Parses the experiment configuration from the metadata + */ +async function getExperimentConfig(pluginOptions, metadata, overrides) { + const id = toClassName(metadata.value || metadata.experiment); + if (!id) { + return null; } - const usp = new URLSearchParams(window.location.search); - const forcedVariant = usp.has(pluginOptions.experimentsQueryParameter) - ? usp.get(pluginOptions.experimentsQueryParameter).split('/')[1] - : null; - if (!experimentConfig.run && !forcedVariant) { - // eslint-disable-next-line no-console - console.warn('Experiment will not run. It is either not active or its configured audiences are not resolved.'); - return false; + let pages = metadata.variants || metadata.url; + + // Backward compatibility + if (!pages) { + pages = getMetadata('instant-experiment'); } - // eslint-disable-next-line no-console - console.debug(`running experiment (${window.hlx.experiment.id}) -> ${window.hlx.experiment.selectedVariant}`); - - if (experimentConfig.selectedVariant === experimentConfig.variantNames[0]) { - document.body.classList.add(`experiment-${context.toClassName(experimentConfig.id)}`); - document.body.classList.add(`variant-${context.toClassName(experimentConfig.selectedVariant)}`); - context.sampleRUM('experiment', { - source: experimentConfig.id, - target: experimentConfig.selectedVariant, - }); - return false; + if (metadata.audience) { + metadata.audiences = metadata.audience; } - const { pages } = experimentConfig.variants[experimentConfig.selectedVariant]; + const nbOfVariants = Number(pages); + pages = Number.isNaN(nbOfVariants) + ? stringToArray(pages).map((p) => new URL(p.trim(), window.location).pathname) + : new Array(nbOfVariants).fill(window.location.pathname); if (!pages.length) { - return false; + return null; } - const currentPath = window.location.pathname; - const control = experimentConfig.variants[experimentConfig.variantNames[0]]; - const index = control.pages.indexOf(currentPath); - if (index < 0) { - return false; + const thumbnailMeta = document.querySelector('meta[property="og:image:secure_url"]') + || document.querySelector('meta[property="og:image"]'); + const thumbnail = thumbnailMeta ? thumbnailMeta.getAttribute('content') : ''; + + const audiences = stringToArray(metadata.audiences).map(toClassName); + + const splits = metadata.split + ? (() => { + const splitValues = stringToArray(metadata.split).map( + (i) => parseFloat(i) / 100, + ); + + // If fewer splits than pages, pad with zeros + if (splitValues.length < pages.length) { + return [ + ...splitValues, + ...Array(pages.length - splitValues.length).fill(0), + ]; + } + + // If more splits than needed, truncate + if (splitValues.length > pages.length) { + return splitValues.slice(0, pages.length); + } + + return splitValues; + })() : [...new Array(pages.length)].map(() => 1 / (pages.length + 1)); + + const variantNames = []; + variantNames.push('control'); + + const variants = {}; + variants.control = { + percentageSplit: '', + pages: [window.location.pathname], + label: 'Control', + }; + + // get the customized name for the variant in page metadata and manifest + const labelNames = stringToArray(metadata.name)?.length + ? stringToArray(metadata.name) + : stringToArray(depluralizeProps(metadata, ['variantName']).variantName); + + pages.forEach((page, i) => { + const vname = `challenger-${i + 1}`; + // label with custom name or default + const customLabel = labelNames.length > i ? labelNames[i] : `Challenger ${i + 1}`; + + variantNames.push(vname); + variants[vname] = { + percentageSplit: `${splits[i].toFixed(4)}`, + pages: [page], + blocks: [], + label: customLabel, + }; + }); + inferEmptyPercentageSplits(Object.values(variants)); + + const resolvedAudiences = await getResolvedAudiences( + audiences, + pluginOptions, + ); + + const startDate = metadata.startDate ? new Date(metadata.startDate) : null; + const endDate = metadata.endDate ? new Date(metadata.endDate) : null; + + const config = { + id, + label: `Experiment ${metadata.value || metadata.experiment}`, + status: metadata.status || 'active', + audiences, + endDate, + optimizingTarget: metadata.optimizingTarget || 'conversion', + resolvedAudiences, + startDate, + variants, + variantNames, + thumbnail, + }; + + config.run = ( + // experiment is active or forced + (['active', 'on', 'true'].includes(toClassName(config.status)) || overrides.value) + // experiment has resolved audiences if configured + && (!resolvedAudiences || resolvedAudiences.length) + // forced audience resolves if defined + && (!overrides.audience || audiences.includes(overrides.audience)) + && (!startDate || startDate <= Date.now()) + && (!endDate || endDate > Date.now()) + ); + + if (!config.run) { + return config; } - // Fullpage content experiment - document.body.classList.add(`experiment-${context.toClassName(experimentConfig.id)}`); - let result; - if (pages[index] !== currentPath) { - result = await replaceInner(pages[index], document.querySelector('main')); + const [, forcedVariant] = (Array.isArray(overrides.value) + ? overrides.value + : stringToArray(overrides.value)) + .map((value) => value?.split('/')) + .find(([experiment]) => toClassName(experiment) === config.id) || []; + if (variantNames.includes(toClassName(forcedVariant))) { + config.selectedVariant = toClassName(forcedVariant); + } else if (overrides.variant && variantNames.includes(overrides.variant)) { + config.selectedVariant = toClassName(overrides.variant); } else { - result = currentPath; - } - experimentConfig.servedExperience = result || currentPath; - if (!result) { - // eslint-disable-next-line no-console - console.debug(`failed to serve variant ${window.hlx.experiment.selectedVariant}. Falling back to ${experimentConfig.variantNames[0]}.`); + // eslint-disable-next-line import/extensions + const { ued } = await import('./ued.js'); + const decision = ued.evaluateDecisionPolicy(toDecisionPolicy(config), {}); + config.selectedVariant = decision.items[0].id; } - document.body.classList.add(`variant-${context.toClassName(result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0])}`); - context.sampleRUM('experiment', { - source: experimentConfig.id, - target: result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0], - }); - return result; + + return config; } -export async function runCampaign(document, options, context) { - if (isBot()) { - return false; - } +/** + * Parses the campaign manifest. + */ +function parseExperimentManifest(entries) { + return Object.values(Object.groupBy( + entries.map((e) => depluralizeProps(e, ['experiment', 'variant', 'split', 'name'])), + ({ experiment }) => experiment, + )).map(aggregateEntries('experiment', ['split', 'url', 'variant', 'name'])); +} - const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; - const usp = new URLSearchParams(window.location.search); - const campaign = (usp.has(pluginOptions.campaignsQueryParameter) - ? context.toClassName(usp.get(pluginOptions.campaignsQueryParameter)) - : null) - || (usp.has('utm_campaign') ? context.toClassName(usp.get('utm_campaign')) : null); - if (!campaign) { - return false; +function getUrlFromExperimentConfig(config) { + return config.run + ? config.variants[config.selectedVariant].pages[0] + : null; +} + +async function runExperiment(document, pluginOptions) { + return applyAllModifications( + pluginOptions.experimentsMetaTagPrefix, + pluginOptions.experimentsQueryParameter, + pluginOptions, + getExperimentConfig, + parseExperimentManifest, + getUrlFromExperimentConfig, + (el, config, result) => { + fireRUM('experiment', config, pluginOptions, result); + // dispatch event + const { id, selectedVariant, variantNames } = config; + const variant = result ? selectedVariant : variantNames[0]; + el.dataset.experiment = id; + el.dataset.variant = variant; + el.classList.add(`experiment-${toClassName(id)}`); + el.classList.add(`variant-${toClassName(variant)}`); + document.dispatchEvent(new CustomEvent('aem:experimentation', { + detail: { + element: el, + type: 'experiment', + experiment: id, + variant, + }, + })); + }, + ); +} + +/** + * Parses the campaign configuration from the metadata + */ +async function getCampaignConfig(pluginOptions, metadata, overrides) { + if (!Object.keys(metadata).length || (Object.keys(metadata).length === 1 && metadata.manifest)) { + return null; } - let audiences = context.getMetadata(`${pluginOptions.campaignsMetaTagPrefix}-audience`); - let resolvedAudiences = null; - if (audiences) { - audiences = audiences.split(',').map(context.toClassName); - resolvedAudiences = await getResolvedAudiences(audiences, pluginOptions, context); - if (!!resolvedAudiences && !resolvedAudiences.length) { - return false; + // Check UTM parameters + let campaign = overrides.value; + if (!campaign) { + const usp = new URLSearchParams(window.location.search); + if (usp.has('utm_campaign')) { + campaign = toClassName(usp.get('utm_campaign')); } + } else { + campaign = toClassName(campaign); } - const allowedCampaigns = context.getAllMetadata(pluginOptions.campaignsMetaTagPrefix); - if (!Object.keys(allowedCampaigns).includes(campaign)) { - return false; + if (metadata.audience) { + metadata.audiences = metadata.audience; } - const urlString = allowedCampaigns[campaign]; - if (!urlString) { - return false; + const audiences = stringToArray(metadata.audiences).map(toClassName); + const resolvedAudiences = await getResolvedAudiences( + audiences, + pluginOptions, + ); + if (resolvedAudiences && !resolvedAudiences.length) { + return null; } - window.hlx.campaign = { selectedCampaign: campaign }; - if (resolvedAudiences) { - window.hlx.campaign.resolvedAudiences = window.hlx.campaign; - } + const configuredCampaigns = Object.fromEntries(Object.entries(metadata.campaigns || metadata) + .filter(([key]) => !['audience', 'audiences'].includes(key))); - try { - const url = new URL(urlString); - const result = await replaceInner(url.pathname, document.querySelector('main')); - window.hlx.campaign.servedExperience = result || window.location.pathname; - if (!result) { - // eslint-disable-next-line no-console - console.debug(`failed to serve campaign ${campaign}. Falling back to default content.`); - } - document.body.classList.add(`campaign-${campaign}`); - context.sampleRUM('campaign', { - source: window.location.href, - target: result ? campaign : 'default', + return { + audiences, + configuredCampaigns, + resolvedAudiences, + selectedCampaign: campaign && (metadata.campaigns || metadata)[campaign] + ? campaign + : null, + }; +} + +/** + * Parses the campaign manifest. + */ +function parseCampaignManifest(entries) { + return Object.values(Object.groupBy( + entries.map((e) => depluralizeProps(e, ['campaign'])), + ({ selector }) => selector, + )) + .map(aggregateEntries('campaign', ['campaign', 'url'])) + .map((e) => { + const campaigns = e.campaign; + delete e.campaign; + e.campaigns = {}; + campaigns.forEach((a, i) => { + e.campaigns[toClassName(a)] = e.url[i]; + }); + delete e.url; + return e; }); - return result; - } catch (err) { - // eslint-disable-next-line no-console - console.error(err); - return false; - } } -export async function serveAudience(document, options, context) { - if (isBot()) { - return false; - } +function getUrlFromCampaignConfig(config) { + return config.selectedCampaign + ? config.configuredCampaigns[config.selectedCampaign] + : null; +} - const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; - const configuredAudiences = context.getAllMetadata(pluginOptions.audiencesMetaTagPrefix); - if (!Object.keys(configuredAudiences).length) { - return false; +async function runCampaign(document, pluginOptions) { + return applyAllModifications( + pluginOptions.campaignsMetaTagPrefix, + pluginOptions.campaignsQueryParameter, + pluginOptions, + getCampaignConfig, + parseCampaignManifest, + getUrlFromCampaignConfig, + (el, config, result) => { + fireRUM('campaign', config, pluginOptions, result); + // dispatch event + const { selectedCampaign = 'default' } = config; + const campaign = result ? toClassName(selectedCampaign) : 'default'; + el.dataset.audience = selectedCampaign; + el.dataset.audiences = Object.keys(pluginOptions.audiences).join(','); + el.classList.add(`campaign-${campaign}`); + document.dispatchEvent(new CustomEvent('aem:experimentation', { + detail: { + element: el, + type: 'campaign', + campaign, + }, + })); + }, + ); +} + +/** + * Parses the audience configuration from the metadata + */ +async function getAudienceConfig(pluginOptions, metadata, overrides) { + if (!Object.keys(metadata).length || (Object.keys(metadata).length === 1 && metadata.manifest)) { + return null; } - const audiences = await getResolvedAudiences( - Object.keys(configuredAudiences).map(context.toClassName), + const configuredAudiencesName = Object.keys(metadata.audiences || metadata).map(toClassName); + const resolvedAudiences = await getResolvedAudiences( + configuredAudiencesName, pluginOptions, - context, ); - if (!audiences || !audiences.length) { + if (resolvedAudiences && !resolvedAudiences.length) { return false; } - const usp = new URLSearchParams(window.location.search); - const forcedAudience = usp.has(pluginOptions.audiencesQueryParameter) - ? context.toClassName(usp.get(pluginOptions.audiencesQueryParameter)) - : null; - - const selectedAudience = forcedAudience || audiences[0]; - const urlString = configuredAudiences[selectedAudience]; - if (!urlString) { - return false; - } + const selectedAudience = overrides.audience || resolvedAudiences[0]; - window.hlx.audience = { selectedAudience }; + return { + configuredAudiences: metadata.audiences || metadata, + resolvedAudiences, + selectedAudience, + }; +} - try { - const url = new URL(urlString); - const result = await replaceInner(url.pathname, document.querySelector('main')); - window.hlx.audience.servedExperience = result || window.location.pathname; - if (!result) { - // eslint-disable-next-line no-console - console.debug(`failed to serve audience ${selectedAudience}. Falling back to default content.`); - } - document.body.classList.add(audiences.map((audience) => `audience-${audience}`)); - context.sampleRUM('audiences', { - source: window.location.href, - target: result ? forcedAudience || audiences.join(',') : 'default', +/** + * Parses the audience manifest. + */ +function parseAudienceManifest(entries) { + return Object.values(Object.groupBy( + entries.map((e) => depluralizeProps(e, ['audience'])), + ({ selector }) => selector, + )) + .map(aggregateEntries('audience', ['audience', 'url'])) + .map((e) => { + const audiences = e.audience; + delete e.audience; + e.audiences = {}; + audiences.forEach((a, i) => { + e.audiences[toClassName(a)] = e.url[i]; + }); + delete e.url; + return e; }); - return result; - } catch (err) { - // eslint-disable-next-line no-console - console.error(err); - return false; - } } -window.hlx.patchBlockConfig?.push((config) => { - const { experiment } = window.hlx; +function getUrlFromAudienceConfig(config) { + return config.selectedAudience + ? config.configuredAudiences[config.selectedAudience] + : null; +} - // No experiment is running - if (!experiment || !experiment.run) { - return config; - } +async function serveAudience(document, pluginOptions) { + document.body.dataset.audiences = Object.keys(pluginOptions.audiences).join(','); + return applyAllModifications( + pluginOptions.audiencesMetaTagPrefix, + pluginOptions.audiencesQueryParameter, + pluginOptions, + getAudienceConfig, + parseAudienceManifest, + getUrlFromAudienceConfig, + (el, config, result) => { + fireRUM('audience', config, pluginOptions, result); + // dispatch event + const { selectedAudience = 'default' } = config; + const audience = result ? toClassName(selectedAudience) : 'default'; + el.dataset.audience = audience; + el.classList.add(`audience-${audience}`); + document.dispatchEvent(new CustomEvent('aem:experimentation', { + detail: { + element: el, + type: 'audience', + audience, + }, + })); + }, + ); +} - // The current experiment does not modify the block - if (experiment.selectedVariant === experiment.variantNames[0] - || !experiment.variants[experiment.variantNames[0]].blocks - || !experiment.variants[experiment.variantNames[0]].blocks.includes(config.blockName)) { - return config; - } +export async function loadEager(document, options = {}) { + console.log("xinyi running loadeager") + const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; + setDebugMode(window.location, pluginOptions); - // The current experiment does not modify the block code - const variant = experiment.variants[experiment.selectedVariant]; - if (!variant.blocks.length) { - return config; - } + const ns = window.aem || window.hlx || {}; + ns.audiences = await serveAudience(document, pluginOptions); + ns.experiments = await runExperiment(document, pluginOptions); + ns.campaigns = await runCampaign(document, pluginOptions); - let index = experiment.variants[experiment.variantNames[0]].blocks.indexOf(''); - if (index < 0) { - index = experiment.variants[experiment.variantNames[0]].blocks.indexOf(config.blockName); - } - if (index < 0) { - index = experiment.variants[experiment.variantNames[0]].blocks.indexOf(`/blocks/${config.blockName}`); - } - if (index < 0) { - return config; - } + // Backward compatibility + ns.experiment = ns.experiments.find((e) => e.type === 'page'); + ns.audience = ns.audiences.find((e) => e.type === 'page'); + ns.campaign = ns.campaigns.find((e) => e.type === 'page'); - let origin = ''; - let path; - if (/^https?:\/\//.test(variant.blocks[index])) { - const url = new URL(variant.blocks[index]); - // Experimenting from a different branch - if (url.origin !== window.location.origin) { - origin = url.origin; - } - // Experimenting from a block path - if (url.pathname !== '/') { - path = url.pathname; - } else { - path = `/blocks/${config.blockName}`; - } - } else { // Experimenting from a different branch on the same branch - path = `/blocks/${variant.blocks[index]}`; - } - if (!origin && !path) { - return config; + if (isDebugEnabled) { + setupCommunicationLayer(pluginOptions); } +} - const { codeBasePath } = window.hlx; - return { - ...config, - cssPath: `${origin}${codeBasePath}${path}/${config.blockName}.css`, - jsPath: `${origin}${codeBasePath}${path}/${config.blockName}.js`, - }; -}); - -let isAdjusted = false; -function adjustedRumSamplingRate(checkpoint, options, context) { - const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; - return (data) => { - if (!window.hlx.rum.isSelected && !isAdjusted) { - isAdjusted = true; - // adjust sampling rate based on project config … - window.hlx.rum.weight = Math.min( - window.hlx.rum.weight, - // … but limit it to the 10% sampling at max to avoid losing anonymization - // and reduce burden on the backend - Math.max(pluginOptions.rumSamplingRate, MAX_SAMPLING_RATE), - ); - window.hlx.rum.isSelected = (window.hlx.rum.random * window.hlx.rum.weight < 1); - if (window.hlx.rum.isSelected) { - context.sampleRUM(checkpoint, data); +/** + * Post-message communication layer for older Universal Editor implementations + */ +function setupCommunicationLayer(options) { + window.addEventListener('message', async (event) => { + if (event.data?.type === 'hlx:experimentation-get-config') { + try { + const safeClone = JSON.parse(JSON.stringify(window.hlx || window.aem || {})); + + if (options.prodHost) { + safeClone.prodHost = options.prodHost; + } + + event.source.postMessage({ + type: 'hlx:experimentation-config', + config: safeClone, + source: 'engine-post-message-response', + }, '*'); + } catch (error) { + console.error('Error handling post-message experimentation request:', error); } } - return true; - }; -} - -function adjustRumSampligRate(document, options, context) { - const checkpoints = ['audiences', 'campaign', 'experiment']; - // if (context.sampleRUM.always) { // RUM v1.x - // checkpoints.forEach((ck) => { - // context.sampleRUM.always.on(ck, adjustedRumSamplingRate(ck, options, context)); - // }); - // } else { // RUM 2.x - // document.addEventListener('rum', (event) => { - // if (event.detail - // && event.detail.checkpoint - // && checkpoints.includes(event.detail.checkpoint)) { - // adjustedRumSamplingRate(event.detail.checkpoint, options, context); - // } - // }); - // } -} - -export async function loadEager(document, options, context) { - adjustRumSampligRate(document, options, context); - let res = await runCampaign(document, options, context); - if (!res) { - res = await runExperiment(document, options, context); - } - if (!res) { - res = await serveAudience(document, options, context); - } + }); } -export async function loadLazy(document, options, context) { - const pluginOptions = { - ...DEFAULT_OPTIONS, - ...(options || {}), - }; +export async function loadLazy(document, options = {}) { // do not show the experimentation pill on prod domains - if (window.location.hostname.endsWith('.live') - || (typeof options.isProd === 'function' && options.isProd()) - || (options.prodHost - && (options.prodHost === window.location.host - || options.prodHost === window.location.hostname - || options.prodHost === window.location.origin))) { + if (!isDebugEnabled) { return; } - // eslint-disable-next-line import/no-cycle - const preview = await import('./preview.js'); - preview.default(document, pluginOptions, { ...context, getResolvedAudiences }); } diff --git a/plugins/experimentation/src/preview.css b/plugins/experimentation/src/preview.css deleted file mode 100644 index 3257e95..0000000 --- a/plugins/experimentation/src/preview.css +++ /dev/null @@ -1,289 +0,0 @@ -/* - * Copyright 2022 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ -[hidden] { - display: none !important; -} - -.hlx-highlight { - --highlight-size: .5rem; - - outline-color: #888; - outline-offset: calc(-1 * var(--highlight-size)); - outline-style: dashed; - outline-width: var(--highlight-size); - background-color: #8882; -} - -.hlx-preview-overlay { - z-index: 99999; - position: fixed; - color: #eee; - font-size: 1rem; - font-weight: 600; - display: flex; - flex-direction: column; - gap: .5rem; - inset: auto auto 1em; - align-items: center; - justify-content: flex-end; - width: 100%; -} - -.hlx-badge { - --color: #888; - - border-radius: 2em; - background-color: var(--color); - border-style: solid; - border-color: #fff; - color: #eee; - padding: 1em 1.5em; - cursor: pointer; - display: flex; - align-items: center; - position: relative; - font-size: inherit; - overflow: initial; - margin: 0; - justify-content: space-between; - text-transform: none; -} - -.hlx-badge:focus, -.hlx-badge:hover { - --color: #888; -} - -.hlx-badge:focus-visible { - outline-style: solid; - outline-width: .25em; -} - -.hlx-badge > span { - user-select: none; -} - -.hlx-badge .hlx-open { - box-sizing: border-box; - position: relative; - display: block; - width: 22px; - height: 22px; - border: 2px solid; - border-radius: 100px; - margin-left: 16px; -} - -.hlx-badge .hlx-open::after { - content: ""; - display: block; - box-sizing: border-box; - position: absolute; - width: 6px; - height: 6px; - border-top: 2px solid; - border-right: 2px solid; - transform: rotate(-45deg); - left: 6px; - bottom: 5px; -} - -.hlx-badge.hlx-testing { - background-color: #fa0f00; - color: #fff; -} - -.hlx-popup { - position: absolute; - display: grid; - grid-template: - "header" min-content - "content" 1fr; - bottom: 6.5em; - left: 50%; - transform: translateX(-50%); - max-height: calc(100vh - 100px - var(--nav-height, 100px)); - max-width: calc(100vw - 2em); - min-width: calc(300px - 2em); - background-color: #444; - border-radius: 16px; - box-shadow: 0 0 10px #000; - font-size: 12px; - text-align: initial; - white-space: initial; -} - -.hlx-popup a:any-link { - color: #eee; - border: 2px solid; - padding: 5px 12px; - display: inline-block; - border-radius: 20px; - text-decoration: none; -} - -.hlx-popup-header { - display: grid; - grid-area: header; - grid-template: - "label actions" - "description actions" - / 1fr min-content; - background-color: #222; - border-radius: 16px 16px 0 0; - padding: 24px 16px; -} - -.hlx-popup-items { - overflow-y: auto; - grid-area: content; - scrollbar-gutter: stable; - scrollbar-width: thin; -} - -.hlx-popup-header-label { - grid-area: label; -} - -.hlx-popup-header-description { - grid-area: description; -} - -.hlx-popup-header-actions { - grid-area: actions; - display: flex; - flex-direction: column; -} - -.hlx-popup h4, .hlx-popup h5 { - margin: 0; -} - -.hlx-popup h4 { - font-size: 16px; -} - -.hlx-popup h5 { - font-size: 14px; -} - - -.hlx-popup p { - margin: 0; -} - -.hlx-popup::before { - content: ''; - width: 0; - height: 0; - position: absolute; - border-left: 15px solid transparent; - border-right: 15px solid transparent; - border-top: 15px solid #444; - bottom: -15px; - right: 50%; - transform: translateX(50%); -} - -.hlx-hidden { - display: none; -} - -.hlx-badge.is-active, -.hlx-badge[aria-pressed="true"] { - --color: #280; -} - -.hlx-badge.is-inactive, -.hlx-badge[aria-pressed="false"] { - --color: #fa0f00; -} - -.hlx-popup-item { - display: grid; - grid-template: - "label actions" - "description actions" - / 1fr min-content; - margin: 1em; - padding: 1em; - border-radius: 1em; - gap: .5em 1em; -} - -.hlx-popup-item-label { - grid-area: label; - white-space: nowrap; -} - -.hlx-popup-item-description { - grid-area: description; -} - -.hlx-popup-item-actions { - grid-area: actions; - display: flex; - flex-direction: column; -} - -.hlx-popup-item.is-selected { - background-color: #666; -} - -.hlx-popup-item .hlx-button { - flex: 0 0 auto; -} - -@media (width >= 600px) { - .hlx-highlight { - --highlight-size: .75rem; - } - - .hlx-preview-overlay { - right: 1em; - align-items: end; - font-size: 1.25rem; - } - - .hlx-popup { - right: 0; - left: auto; - transform: none; - min-width: 300px; - bottom: 8em; - } - - .hlx-popup::before { - right: 26px; - transform: none; - } -} - -@media (width >= 900px) { - .hlx-highlight { - --highlight-size: 1rem; - } - - .hlx-preview-overlay { - flex-flow: row wrap-reverse; - justify-content: flex-end; - font-size: 1.5rem; - } - - .hlx-popup { - bottom: 9em; - } - - .hlx-popup::before { - right: 32px; - } -} diff --git a/plugins/experimentation/src/preview.js b/plugins/experimentation/src/preview.js deleted file mode 100644 index 8cde95b..0000000 --- a/plugins/experimentation/src/preview.js +++ /dev/null @@ -1,525 +0,0 @@ -/* - * Copyright 2022 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -const DOMAIN_KEY_NAME = 'aem-domainkey'; - -class AemExperimentationBar extends HTMLElement { - connectedCallback() { - // Create a shadow root - const shadow = this.attachShadow({ mode: 'open' }); - - const cssPath = new URL(new Error().stack.split('\n')[2].match(/[a-z]+?:\/\/.*?\/[^:]+/)[0]).pathname.replace('preview.js', 'preview.css'); - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = cssPath; - link.onload = () => { - shadow.querySelector('.hlx-preview-overlay').removeAttribute('hidden'); - }; - shadow.append(link); - - const el = document.createElement('div'); - el.className = 'hlx-preview-overlay'; - el.setAttribute('hidden', true); - shadow.append(el); - } -} -customElements.define('aem-experimentation-bar', AemExperimentationBar); - -function createPreviewOverlay() { - const overlay = document.createElement('aem-experimentation-bar'); - return overlay; -} - -function getOverlay() { - let overlay = document.querySelector('aem-experimentation-bar')?.shadowRoot.children[1]; - if (!overlay) { - const el = createPreviewOverlay(); - document.body.append(el); - [, overlay] = el.shadowRoot.children; - } - return overlay; -} - -function createButton(label) { - const button = document.createElement('button'); - button.className = 'hlx-badge'; - const text = document.createElement('span'); - text.innerHTML = label; - button.append(text); - return button; -} - -function createPopupItem(item) { - const actions = typeof item === 'object' - ? item.actions.map((action) => (action.href - ? `` - : ``)) - : []; - const div = document.createElement('div'); - div.className = `hlx-popup-item${item.isSelected ? ' is-selected' : ''}`; - div.innerHTML = ` -
${typeof item === 'object' ? item.label : item}
- ${item.description ? `
${item.description}
` : ''} - ${actions.length ? `
${actions}
` : ''}`; - const buttons = [...div.querySelectorAll('.hlx-button a')]; - item.actions?.forEach((action, index) => { - if (action.onclick) { - buttons[index].addEventListener('click', action.onclick); - } - }); - return div; -} - -function createPopupDialog(header, items = []) { - const actions = typeof header === 'object' - ? (header.actions || []).map((action) => (action.href - ? `` - : ``)) - : []; - const popup = document.createElement('div'); - popup.className = 'hlx-popup hlx-hidden'; - popup.innerHTML = ` -
-
${typeof header === 'object' ? header.label : header}
- ${header.description ? `
${header.description}
` : ''} - ${actions.length ? `
${actions}
` : ''} -
-
`; - const list = popup.querySelector('.hlx-popup-items'); - items.forEach((item) => { - list.append(createPopupItem(item)); - }); - const buttons = [...popup.querySelectorAll('.hlx-popup-header-actions .hlx-button a')]; - header.actions?.forEach((action, index) => { - if (action.onclick) { - buttons[index].addEventListener('click', action.onclick); - } - }); - return popup; -} - -function createPopupButton(label, header, items) { - const button = createButton(label); - const popup = createPopupDialog(header, items); - button.innerHTML += ''; - button.append(popup); - button.addEventListener('click', () => { - popup.classList.toggle('hlx-hidden'); - }); - return button; -} - -// eslint-disable-next-line no-unused-vars -function createToggleButton(label) { - const button = document.createElement('div'); - button.className = 'hlx-badge'; - button.role = 'button'; - button.setAttribute('aria-pressed', false); - button.setAttribute('tabindex', 0); - const text = document.createElement('span'); - text.innerHTML = label; - button.append(text); - button.addEventListener('click', () => { - button.setAttribute('aria-pressed', button.getAttribute('aria-pressed') === 'false'); - }); - return button; -} - -const percentformat = new Intl.NumberFormat('en-US', { style: 'percent', maximumSignificantDigits: 3 }); -const countformat = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 2 }); -const significanceformat = { - format: (value) => { - if (value < 0.005) { - return 'highly significant'; - } - if (value < 0.05) { - return 'significant'; - } - if (value < 0.1) { - return 'marginally significant'; - } - return 'not significant'; - }, -}; -const bigcountformat = { - format: (value) => { - if (value > 1000000) { - return `${countformat.format(value / 1000000)}M`; - } - if (value > 1000) { - return `${countformat.format(value / 1000)}K`; - } - return countformat.format(value); - }, -}; - -function createVariant(experiment, variantName, config, options) { - const selectedVariant = config?.selectedVariant || config?.variantNames[0]; - const variant = config.variants[variantName]; - const split = variant.percentageSplit; - const percentage = percentformat.format(split); - - const experimentURL = new URL(window.location.href); - // this will retain other query params such as ?rum=on - experimentURL.searchParams.set(options.experimentsQueryParameter, `${experiment}/${variantName}`); - - return { - label: `${variantName}`, - description: ` -

${variant.label}

-

(${percentage} split)

-

`, - actions: [{ label: 'Simulate', href: experimentURL.href }], - isSelected: selectedVariant === variantName, - }; -} - -async function fetchRumData(experiment, options) { - if (!options.domainKey) { - // eslint-disable-next-line no-console - console.warn('Cannot show RUM data. No `domainKey` configured.'); - return null; - } - if (!options.prodHost && (typeof options.isProd !== 'function' || !options.isProd())) { - // eslint-disable-next-line no-console - console.warn('Cannot show RUM data. No `prodHost` configured or custom `isProd` method provided.'); - return null; - } - - // the query is a bit slow, so I'm only fetching the results when the popup is opened - const resultsURL = new URL('https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-experiments'); - // restrict results to the production host, this also reduces query cost - if (typeof options.isProd === 'function' && options.isProd()) { - resultsURL.searchParams.set('url', window.location.host); - } else if (options.prodHost) { - resultsURL.searchParams.set('url', options.prodHost); - } - resultsURL.searchParams.set('domainkey', options.domainKey); - resultsURL.searchParams.set('experiment', experiment); - resultsURL.searchParams.set('conversioncheckpoint', options.conversionName); - - const response = await fetch(resultsURL.href); - if (!response.ok) { - return null; - } - - const { results } = await response.json(); - const { data } = results; - if (!data.length) { - return null; - } - - const numberify = (obj) => Object.entries(obj).reduce((o, [k, v]) => { - o[k] = Number.parseFloat(v); - o[k] = Number.isNaN(o[k]) ? v : o[k]; - return o; - }, {}); - - const variantsAsNums = data.map(numberify); - const totals = Object.entries( - variantsAsNums.reduce((o, v) => { - Object.entries(v).forEach(([k, val]) => { - if (typeof val === 'number' && Number.isInteger(val) && k.startsWith('variant_')) { - o[k] = (o[k] || 0) + val; - } else if (typeof val === 'number' && Number.isInteger(val) && k.startsWith('control_')) { - o[k] = val; - } - }); - return o; - }, {}), - ).reduce((o, [k, v]) => { - o[k] = v; - const vkey = k.replace(/^(variant|control)_/, 'variant_'); - const ckey = k.replace(/^(variant|control)_/, 'control_'); - const tkey = k.replace(/^(variant|control)_/, 'total_'); - if (!Number.isNaN(o[ckey]) && !Number.isNaN(o[vkey])) { - o[tkey] = o[ckey] + o[vkey]; - } - return o; - }, {}); - const richVariants = variantsAsNums - .map((v) => ({ - ...v, - allocation_rate: v.variant_experimentations / totals.total_experimentations, - })) - .reduce((o, v) => { - const variantName = v.variant; - o[variantName] = v; - return o; - }, { - control: { - variant: 'control', - ...Object.entries(variantsAsNums[0]).reduce((k, v) => { - const [key, val] = v; - if (key.startsWith('control_')) { - k[key.replace(/^control_/, 'variant_')] = val; - } - return k; - }, {}), - }, - }); - const winner = variantsAsNums.reduce((w, v) => { - if (v.variant_conversion_rate > w.conversion_rate && v.p_value < 0.05) { - w.conversion_rate = v.variant_conversion_rate; - w.p_value = v.p_value; - w.variant = v.variant; - } - return w; - }, { variant: 'control', p_value: 1, conversion_rate: 0 }); - - return { - richVariants, - totals, - variantsAsNums, - winner, - }; -} - -function populatePerformanceMetrics(div, config, { - richVariants, totals, variantsAsNums, winner, -}, conversionName = 'click') { - // add summary - const summary = div.querySelector('.hlx-info'); - summary.innerHTML = `Showing results for ${bigcountformat.format(totals.total_experimentations)} visits and ${bigcountformat.format(totals.total_conversions)} conversions: `; - if (totals.total_conversion_events < 500 && winner.p_value > 0.05) { - summary.innerHTML += ` not yet enough data to determine a winner. Keep going until you get ${bigcountformat.format((500 * totals.total_experimentations) / totals.total_conversion_events)} visits.`; - } else if (winner.p_value > 0.05) { - summary.innerHTML += ' no significant difference between variants. In doubt, stick with control.'; - } else if (winner.variant === 'control') { - summary.innerHTML += ' Stick with control. No variant is better than the control.'; - } else { - summary.innerHTML += ` ${winner.variant} is the winner.`; - } - - // add traffic allocation to control and each variant - config.variantNames.forEach((variantName, index) => { - const variantDiv = document.querySelector('aem-experimentation-bar')?.shadowRoot.querySelectorAll('.hlx-popup-item')[index]; - const percentage = variantDiv.querySelector('.percentage'); - percentage.innerHTML = ` - ${bigcountformat.format(richVariants[variantName].variant_conversions)} ${conversionName} events / - ${bigcountformat.format(richVariants[variantName].variant_experimentations)} visits - (${percentformat.format(richVariants[variantName].variant_experimentations / totals.total_experimentations)} split) - `; - }); - - // add click rate and significance to each variant - variantsAsNums.forEach((result) => { - const variant = document.querySelector('aem-experimentation-bar')?.shadowRoot.querySelectorAll('.hlx-popup-item')[config.variantNames.indexOf(result.variant)]; - if (variant) { - const performance = variant.querySelector('.performance'); - performance.innerHTML = ` - ${conversionName} conversion rate: ${percentformat.format(result.variant_conversion_rate)} - vs. ${percentformat.format(result.control_conversion_rate)} - ${significanceformat.format(result.p_value)} - `; - } - }); -} - -/** - * Create Badge if a Page is enlisted in a AEM Experiment - * @return {Object} returns a badge or empty string - */ -async function decorateExperimentPill(overlay, options, context) { - const config = window?.hlx?.experiment; - const experiment = context.toClassName(context.getMetadata(options.experimentsMetaTag)); - if (!experiment || !config) { - return; - } - // eslint-disable-next-line no-console - console.log('preview experiment', experiment); - - const domainKey = window.localStorage.getItem(DOMAIN_KEY_NAME); - const conversionName = config.conversionName - || context.getMetadata('conversion-name') - || 'click'; - const pill = createPopupButton( - `Experiment: ${config.id}`, - { - label: config.label, - description: ` -
- ${config.status} - ${config.resolvedAudiences ? ', ' : ''} - ${config.resolvedAudiences && config.resolvedAudiences.length ? config.resolvedAudiences[0] : ''} - ${config.resolvedAudiences && !config.resolvedAudiences.length ? 'No audience resolved' : ''} - ${config.variants[config.variantNames[0]].blocks.length ? ', Blocks: ' : ''} - ${config.variants[config.variantNames[0]].blocks.join(',')} -
-
How is it going?
`, - actions: [ - ...config.manifest ? [{ label: 'Manifest', href: config.manifest }] : [], - { - label: '', - onclick: async () => { - // eslint-disable-next-line no-alert - const key = window.prompt( - 'Please enter your domain key:', - window.localStorage.getItem(DOMAIN_KEY_NAME) || '', - ); - if (key && key.match(/[a-f0-9-]+/)) { - window.localStorage.setItem(DOMAIN_KEY_NAME, key); - const performanceMetrics = await fetchRumData(experiment, { - ...options, - conversionName, - domainKey: key, - }); - if (performanceMetrics === null) { - return; - } - populatePerformanceMetrics(pill, config, performanceMetrics, conversionName); - } else if (key === '') { - window.localStorage.removeItem(DOMAIN_KEY_NAME); - } - }, - }, - ], - }, - config.variantNames.map((vname) => createVariant(experiment, vname, config, options)), - ); - if (config.run) { - pill.classList.add(`is-${context.toClassName(config.status)}`); - } - overlay.append(pill); - - const performanceMetrics = await fetchRumData(experiment, { - ...options, domainKey, conversionName, - }); - if (performanceMetrics === null) { - return; - } - populatePerformanceMetrics(pill, config, performanceMetrics, conversionName); -} - -function createCampaign(campaign, isSelected, options) { - const url = new URL(window.location.href); - if (campaign !== 'default') { - url.searchParams.set(options.campaignsQueryParameter, campaign); - } else { - url.searchParams.delete(options.campaignsQueryParameter); - } - - return { - label: `${campaign}`, - actions: [{ label: 'Simulate', href: url.href }], - isSelected, - }; -} - -/** - * Create Badge if a Page is enlisted in a AEM Campaign - * @return {Object} returns a badge or empty string - */ -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) - ? context.toClassName(usp.get(options.audiencesQueryParameter)) - : null; - 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) - ? context.toClassName(usp.get(options.campaignsQueryParameter)) - : null) - || (usp.has('utm_campaign') ? context.toClassName(usp.get('utm_campaign')) : null); - const pill = createPopupButton( - `Campaign: ${campaign || 'default'}`, - { - label: 'Campaigns on this page:', - description: ` -
- ${audiences.length && resolvedAudiences?.length ? `Audience: ${resolvedAudiences[0]}` : ''} - ${audiences.length && !resolvedAudiences?.length ? 'No audience resolved' : ''} - ${!audiences.length || !resolvedAudiences ? 'No audience configured' : ''} -
`, - }, - [ - createCampaign('default', !campaign || !isActive, options), - ...Object.keys(campaigns) - .filter((c) => c !== 'audience') - .map((c) => createCampaign(c, isActive && context.toClassName(campaign) === c, options)), - ], - ); - - if (campaign && isActive) { - pill.classList.add('is-active'); - } - overlay.append(pill); -} - -function createAudience(audience, isSelected, options) { - const url = new URL(window.location.href); - url.searchParams.set(options.audiencesQueryParameter, audience); - - return { - label: `${audience}`, - actions: [{ label: 'Simulate', href: url.href }], - isSelected, - }; -} - -/** - * Create Badge if a Page is enlisted in a AEM Audiences - * @return {Object} returns a badge or empty string - */ -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 context.getResolvedAudiences( - Object.keys(audiences), - options, - context, - ); - const pill = createPopupButton( - 'Audiences', - { - label: 'Audiences for this page:', - }, - [ - createAudience('default', !resolvedAudiences.length || resolvedAudiences[0] === 'default', options), - ...Object.keys(audiences) - .filter((a) => a !== 'audience') - .map((a) => createAudience(a, resolvedAudiences && resolvedAudiences[0] === a, options)), - ], - ); - - if (resolvedAudiences.length) { - pill.classList.add('is-active'); - } - overlay.append(pill); -} - -/** - * Decorates Preview mode badges and overlays - * @return {Object} returns a badge or empty string - */ -export default async function decoratePreviewMode(document, options, context) { - try { - const overlay = getOverlay(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); - } -} diff --git a/scripts/experiment-loader.js b/scripts/experiment-loader.js new file mode 100644 index 0000000..cca7f9f --- /dev/null +++ b/scripts/experiment-loader.js @@ -0,0 +1,92 @@ +/** + * Checks if experimentation is enabled. + * @returns {boolean} True if experimentation is enabled, false otherwise. + */ +const isExperimentationEnabled = () => document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"],[property^="campaign:"],[property^="audience:"]') +|| [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i)); +[...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i)); + +const isProd = (config) => { + if (config?.prodHost) { + return window.location.hostname === config.prodHost; + } + return !window.location.hostname.endsWith('hlx.page') && window.location.hostname !== 'localhost'; +}; + +/** + * Loads the experimentation module (eager). + * @param {Document} document The document object. + * @returns {Promise} A promise that resolves when the experimentation module is loaded. + */ +export async function runExperimentation(document, config) { + if (!isExperimentationEnabled()) { + window.addEventListener('message', async (event) => { + if (event.data?.type === 'hlx:experimentation-get-config') { + event.source.postMessage({ + type: 'hlx:experimentation-config', + config: { experiments: [], audiences: [], campaigns: [] }, + source: 'no-experiments' + }, '*'); + } + }); + return null; + } + + try { + const { loadEager } = await import( + // eslint-disable-next-line import/no-relative-packages + '../plugins/experimentation/src/index.js' + ); + return loadEager(document, config); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to load experimentation module (eager):', error); + return null; + } +} + +/** + * Loads the experimentation module (lazy). + * @param {Document} document The document object. + * @param {Object} config The experimentation configuration object. + * @returns {Promise} A promise that resolves when the experimentation module is loaded. + */ +export async function showExperimentationRail(document, config) { + if (!isExperimentationEnabled()) { + return null; + } + + if (isProd(config)) { + return null; + } + + try { + const { loadLazy } = await import( + // eslint-disable-next-line import/no-relative-packages + '../plugins/experimentation/src/index.js' + ); + await loadLazy(document, config); + + const loadSidekickHandler = () => import('../tools/sidekick/aem-experimentation.js'); + + if (document.querySelector('helix-sidekick, aem-sidekick')) { + await loadSidekickHandler(); + } else { + await new Promise((resolve) => { + document.addEventListener( + 'sidekick-ready', + () => { + loadSidekickHandler().then(resolve); + }, + { once: true }, + ); + }); + } + + return true; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to load experimentation module (lazy):', error); + return null; + } +} diff --git a/scripts/scripts.js b/scripts/scripts.js index 19f29ba..35f894f 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -18,36 +18,22 @@ import { } from './aem.js'; import getAudiences from './utils.js'; +import { + runExperimentation, + showExperimentationRail, +} from './experiment-loader.js'; + +const experimentationConfig = { + prodHost: 'www.securbankdemo.com', + audiences: getAudiences(), +}; + // Add you templates below // window.hlx.templates.add('/templates/my-template'); // Add you plugins below // window.hlx.plugins.add('/plugins/my-plugin.js'); -/** - * Gets all the metadata elements that are in the given scope. - * @param {String} scope The scope/prefix for the metadata - * @returns an array of HTMLElement nodes that match the given scope - */ -export function getAllMetadata(scope) { - return [...document.head.querySelectorAll(`meta[property^="${scope}:"],meta[name^="${scope}-"]`)] - .reduce((res, meta) => { - const id = toClassName(meta.name - ? meta.name.substring(scope.length + 1) - : meta.getAttribute('property').split(':')[1]); - res[id] = meta.getAttribute('content'); - return res; - }, {}); -} - -window.hlx.plugins.add('experimentation', { - condition: () => getMetadata('experiment') - || Object.keys(getAllMetadata('campaign')).length - || Object.keys(getAllMetadata('audience')).length, - options: { audiences: getAudiences() }, - url: '/plugins/experimentation/src/index.js', -}); - // Define an execution context const pluginContext = { getAllMetadata, @@ -141,24 +127,9 @@ export function decorateMain(main) { async function loadEager(doc) { document.documentElement.lang = 'en'; decorateTemplateAndTheme(); + await runExperimentation(doc, experimentationConfig); // await window.hlx.plugins.run('loadEager'); const main = doc.querySelector('main'); - const experimentationOptions = { - prodHost: 'www.securbankdemo.com', - isProd: () => !(window.location.hostname.endsWith('aem.page') - || window.location.hostname === ('localhost')), - rumSamplingRate: 1, - audiences: getAudiences(), - }; - - if (getMetadata('experiment') - || Object.keys(getAllMetadata('campaign')).length - || Object.keys(getAllMetadata('audience')).length) { - // eslint-disable-next-line import/no-relative-packages - const { loadEager: runEager } = await import('../plugins/experimentation/src/index.js'); - await runEager(document, experimentationOptions, pluginContext); - } - if (main) { decorateMain(main); document.body.classList.add('appear'); @@ -171,13 +142,6 @@ async function loadEager(doc) { /* if desktop (proxy for fast connection) or fonts already loaded, load fonts.css */ if (window.innerWidth >= 900 || sessionStorage.getItem('fonts-loaded')) { loadFonts(); - if (getMetadata('experiment') - || Object.keys(getAllMetadata('campaign')).length - || Object.keys(getAllMetadata('audience')).length) { - // eslint-disable-next-line import/no-relative-packages - const { loadLazy: runLazy } = await import('../plugins/experimentation/src/index.js'); - await runLazy(document, experimentationOptions, pluginContext); - } } } catch (e) { // do nothing @@ -204,19 +168,8 @@ async function loadLazy(doc) { sampleRUM('lazy'); - // Add below snippet at the end of the lazy phase - if ((getMetadata('experiment') - || Object.keys(getAllMetadata('campaign')).length - || Object.keys(getAllMetadata('audience')).length)) { - // eslint-disable-next-line import/no-relative-packages - const { loadLazy: runLazy } = await import('../plugins/experimentation/src/index.js'); - await runLazy(document, { - prodHost: 'www.securbankdemo.com', - isProd: () => window.location.hostname.endsWith('aem.page') - || window.location.hostname === ('localhost'), - audiences: getAudiences(), - }, pluginContext); - } + await showExperimentationRail(doc, experimentationConfig); + } /**