,
- * }
- * }
- * };
+ * 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 = `
-
- ${item.description ? `` : ''}
- ${actions.length ? `` : ''}`;
- 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 = `
-
- `;
- 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);
+
}
/**