├── documentation ├── images │ ├── audiences-overlay.png │ ├── campaigns-overlay.png │ └── experiments-overlay.png ├── campaigns.md ├── audiences.md └── experiments.md ├── .stylelintrc.json ├── .releaserc ├── .eslintrc.js ├── .github └── workflows │ └── release.yaml ├── CHANGELOG.md ├── package.json ├── CODE_OF_CONDUCT.md ├── src ├── preview.css ├── ued.js ├── preview.js └── index.js ├── LICENSE.md └── README.md /documentation/images/audiences-overlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/aem-experimentation/main/documentation/images/audiences-overlay.png -------------------------------------------------------------------------------- /documentation/images/campaigns-overlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/aem-experimentation/main/documentation/images/campaigns-overlay.png -------------------------------------------------------------------------------- /documentation/images/experiments-overlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/aem-experimentation/main/documentation/images/experiments-overlay.png -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard" 4 | ], 5 | "rules": { 6 | "declaration-block-no-redundant-longhand-properties": null 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | "@semantic-release/changelog", 7 | "@semantic-release/npm", 8 | ["@semantic-release/git", { 9 | "assets": ["CHANGELOG.md", "package.json"], 10 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 11 | }] 12 | ] 13 | 14 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: 'airbnb-base', 4 | env: { 5 | browser: true, 6 | }, 7 | parser: '@babel/eslint-parser', 8 | parserOptions: { 9 | allowImportExportEverywhere: true, 10 | sourceType: 'module', 11 | requireConfigFile: false, 12 | }, 13 | rules: { 14 | // allow reassigning param 15 | 'no-param-reassign': [2, { props: false }], 16 | 'linebreak-style': ['error', 'unix'], 17 | 'import/extensions': ['error', { 18 | js: 'always', 19 | }], 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | issues: write 17 | pull-requests: write 18 | id-token: write 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 0 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: "lts/*" 28 | - name: Install dependencies 29 | run: npm ci 30 | - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies 31 | run: npm audit signatures 32 | - name: Release 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | run: npx semantic-release -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.4](https://github.com/adobe/aem-experimentation/compare/v1.0.3...v1.0.4) (2025-07-21) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * prevent XSS vulnerabilities in preview overlay ([#52](https://github.com/adobe/aem-experimentation/issues/52)) ([960e094](https://github.com/adobe/aem-experimentation/commit/960e094d33a2b11decd3ad1b2e45d5c5c2366175)) 7 | 8 | ## [1.0.3](https://github.com/adobe/aem-experimentation/compare/v1.0.2...v1.0.3) (2024-10-06) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * regressions from prerendering logic and experimeantation ([5b90510](https://github.com/adobe/aem-experimentation/commit/5b90510168be9f9b55fc71e9c227cadaf481b968)) 14 | 15 | ## [1.0.2](https://github.com/adobe/aem-experimentation/compare/v1.0.1...v1.0.2) (2024-06-11) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * pill css loading on localhost ([a41fafc](https://github.com/adobe/aem-experimentation/commit/a41fafc1003ada023725a451fe2215947a9bdeb9)) 21 | 22 | ## [1.0.1](https://github.com/adobe/aem-experimentation/compare/v1.0.0...v1.0.1) (2024-05-24) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * semantic release ([5040fa8](https://github.com/adobe/aem-experimentation/commit/5040fa88c7a01b032431967e230abaaf6d69f9d6)) 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adobe/aem-experimentation", 3 | "version": "1.0.4", 4 | "main": "src/index.js", 5 | "scripts": { 6 | "lint:js": "eslint src", 7 | "lint:css": "stylelint src/**/*.css", 8 | "lint": "npm run lint:js && npm run lint:css" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+ssh://git@github.com/adobe/aem-experimentation.git" 13 | }, 14 | "author": "Adobe Inc.", 15 | "license": "Apache-2.0", 16 | "keywords": [ 17 | "aem", 18 | "experimentation", 19 | "experience", 20 | "decisioning", 21 | "plugin", 22 | "campaigns", 23 | "audiences" 24 | ], 25 | "bugs": { 26 | "url": "https://github.com/adobe/aem-experimentation/issues" 27 | }, 28 | "homepage": "https://github.com/adobe/aem-experimentation#readme", 29 | "devDependencies": { 30 | "@babel/eslint-parser": "7.22.15", 31 | "@semantic-release/changelog": "6.0.3", 32 | "@semantic-release/git": "10.0.1", 33 | "@semantic-release/npm": "12.0.1", 34 | "eslint": "8.48.0", 35 | "eslint-config-airbnb-base": "15.0.0", 36 | "eslint-plugin-import": "2.28.1", 37 | "semantic-release": "23.1.1", 38 | "stylelint": "15.10.3", 39 | "stylelint-config-standard": "34.0.0" 40 | }, 41 | "private": true 42 | } 43 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Adobe Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at Grp-opensourceoffice@adobe.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /documentation/campaigns.md: -------------------------------------------------------------------------------- 1 | With campaigns you can send out emails or social media posts that link back to your site and that will serve specific offers or versions of your content to the targeted audience. 2 | 3 | ## Set up 4 | 5 | The set up is pretty minimal. Once you've instrumented the experimentation plugin in your AEM website, you are essentially good to go. 6 | 7 | Just keep in mind that if you want to only target specific audiences for that campaign, you'll also need to set up the [audiences](Audiences) accordingly for your project. 8 | 9 | ### Custom options 10 | 11 | By default, the campaigns feature looks at the `Campaign` metadata tags and `campaign` query parameter, but if this clashes with your existing codebase or doesn't feel intuitive to your authors, you can adjust this by passing new options to the plugin. 12 | 13 | For instance, here is an alternate configuration that would use `sale` instead of `campaign`: 14 | ```js 15 | const { loadEager } = await import('../plugins/experimentation/src/index.js'); 16 | await loadEager(document, { 17 | campaignsMetaTagPrefix: 'sale', 18 | campaignsQueryParameter: 'sale', 19 | }, /* plugin execution context */); 20 | ``` 21 | 22 | :mega: The campaign feature also supports the industry-standard Urchin Tracking Module (UTM) `utm_campaign` as query parameter. There is nothing special you need to do to get this working and it will be seamlessly handled the same way as the `campaignsQueryParameter`. This means that both: 23 | 24 | - [https://{ref}--{repo}--{org}.hlx.page/my-page?campaign=xmas]() 25 | - [https://{ref}--{repo}--{org}.hlx.page/my-page?utm_campaign=xmas]() 26 | 27 | would essentially serve you the `xmas` variant of the experience. 28 | 29 | ## Authoring 30 | 31 | Once the above steps are done, your authors are ready to start using campaigns for their experiences. 32 | This is done directly in the page metadata block: 33 | 34 | | Metadata | | 35 | |---------------------|-----------------------------------------------------------------| 36 | | Campaign: Xmas | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-xmas]() | 37 | | Campaign: Halloween | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-halloween]() | 38 | 39 | The notation is pretty flexible and authors can also use `Campaign (Xmas)` or `Campaign Halloween` if this is a preferred notation. 40 | 41 | If you wanted to additionally restrict the campaign to specific audiences, so that for instance your campaigns are only accessible on mobile phone or on iPhone, you'd leverage the [audiences](Audiences) feature and use the following metadata: 42 | 43 | | Metadata | | 44 | |---------------------|-----------------------------------------------------------------| 45 | | Campaign: Xmas | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-xmas]() | 46 | | Campaign: Halloween | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-halloween]() | 47 | | Campaign Audience | mobile, iphone | 48 | 49 | If any of the listed audiences is resolved, then the campaign will run and the matching content will be served. 50 | If you needed both audiences to be resolved, you'd define a new `mobile-iphone` audience in your project and use that in the metadata instead. 51 | 52 | ### Simulation 53 | 54 | Once all of this is set up, authors will have access to an overlay on `localhost` and on the stage environments (i.e. `*.hlx.stage`) that lets them see what campaigns have been configured for the page and switch between each to visualize the content variations accordingly. 55 | 56 | ![audience overlay](./images/campaigns-overlay.png) 57 | 58 | ## Development 59 | 60 | To help developers in designing variants for each campaign, when a campaign is resolved on the page it will automatically add a new CSS class named `campaign-` to the `` element, i.e. `campaign-xmas`. 61 | -------------------------------------------------------------------------------- /documentation/audiences.md: -------------------------------------------------------------------------------- 1 | With audiences you can serve different versions of your content to different groups of users based on the information you can glean from there current session. For instance, you can optimize the experience for: 2 | - mobile vs. desktop 3 | - Chrome vs. Firefox 4 | - [1st vs. returning visitor](https://github.com/hlxsites/wknd/blob/main/scripts/scripts.js#L33-L34) 5 | - [fast vs slow connections](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/effectiveType) 6 | - different geographies via [Geolocation API](https://github.com/hlxsites/wknd/blob/main/scripts/scripts.js#L33-L34) or CDN workers ([Fastly](https://www.fastly.com/documentation/reference/vcl/variables/geolocation/), [Cloudflare](https://developers.cloudflare.com/workers/examples/geolocation-hello-world/)) 7 | - etc. 8 | 9 | ## Set up 10 | 11 | First, you need to define audiences for the project. This is done directly in the project codebase. Audiences are defined as a `Map` of audience names and boolean evaluating (async) functions that check whether the given audience is resolved in the current browsing session. 12 | 13 | You'd typically define the mapping in your AEM's `scripts.js` as follows: 14 | ```js 15 | const geoPromise = (async () => { 16 | const resp = await fetch(/* some geo service*/); 17 | return resp.json(); 18 | })(); 19 | 20 | const AUDIENCES = { 21 | mobile: () => window.innerWidth < 600, 22 | desktop: () => window.innerWidth >= 600, 23 | us: async () => (await geoPromise).region === 'us', 24 | eu: async () => (await geoPromise).region === 'eu', 25 | } 26 | ``` 27 | 28 | As you can see in the example above, functions need to return a boolean value. If the value is truthy, the audience is considered resolved, and if it's falsy then it isn't. You can also use any browser API directly, or rely on external services to resolve an audience. 29 | 30 | :warning: Using external services will have a performance impact on the initial page load as the call will be blocking the page rendering until the async function is fully evaluated. 31 | 32 | The audiences for the project then need to be passed to the plugin initialization as follows: 33 | 34 | ```js 35 | const { loadEager } = await import('../plugins/experimentation/src/index.js'); 36 | await loadEager(document, { audiences: AUDIENCES }, /* plugin execution context */); 37 | ``` 38 | 39 | ### Custom options 40 | 41 | By default, the audience feature looks at the `Audience` metadata tags and `audience` query parameters, but if this clashes with your existing codebase or doesn't feel intuitive to your authors, you can adjust this by passing new options to the plugin. 42 | 43 | For instance, here is an alternate configuration that would use `segment` instead of `audience`: 44 | ```js 45 | const { loadEager } = await import('../plugins/experimentation/src/index.js'); 46 | await loadEager(document, { 47 | audiences: AUDIENCES, 48 | audiencesMetaTagPrefix: 'segment', 49 | audiencesQueryParameter: 'segment', 50 | }, /* plugin execution context */); 51 | ``` 52 | 53 | ## Authoring 54 | 55 | Once the audiences made it into the project codebase, your authors are ready to start using audiences for their experiences. 56 | This is done directly in the page metadata block: 57 | 58 | | Metadata | | 59 | |-------------------|---------------------------------------------------------------| 60 | | Audience: Mobile | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-mobile]() | 61 | | Audience: Desktop | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-desktop]() | 62 | 63 | The notation is pretty flexible and authors can also use `Audience (Mobile)` or `Audience Mobile` if this is a preferred notation. 64 | 65 | ### Simulation 66 | 67 | Once all of this is set up, authors will have access to an overlay on `localhost` and on the stage environments (i.e. `*.hlx.page`) that lets them see what audiences have been configured for the page and switch between each to visualize the content variations accordingly. 68 | 69 | ![audience overlay](./images/audiences-overlay.png) 70 | 71 | The simulation capabilities leverage the `audience` query parameter that is appended to the URL and forcibly let you see the specific content variant. 72 | 73 | ## Development 74 | 75 | To help developers in designing variants for each audience, when audiences are resolved on the page it will automatically add a new CSS class named `audience-` for each to the `` element, i.e. `audience-mobile audience-iphone`. 76 | -------------------------------------------------------------------------------- /src/preview.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | [hidden] { 13 | display: none !important; 14 | } 15 | 16 | .hlx-highlight { 17 | --highlight-size: .5rem; 18 | 19 | outline-color: #888; 20 | outline-offset: calc(-1 * var(--highlight-size)); 21 | outline-style: dashed; 22 | outline-width: var(--highlight-size); 23 | background-color: #8882; 24 | } 25 | 26 | .hlx-preview-overlay { 27 | z-index: 99999; 28 | position: fixed; 29 | color: #eee; 30 | font-size: 1rem; 31 | font-weight: 600; 32 | display: flex; 33 | flex-direction: column; 34 | gap: .5rem; 35 | inset: auto auto 1em; 36 | align-items: center; 37 | justify-content: flex-end; 38 | width: 100%; 39 | } 40 | 41 | .hlx-badge { 42 | --color: #888; 43 | 44 | border-radius: 2em; 45 | background-color: var(--color); 46 | border-style: solid; 47 | border-color: #fff; 48 | color: #eee; 49 | padding: 1em 1.5em; 50 | cursor: pointer; 51 | display: flex; 52 | align-items: center; 53 | position: relative; 54 | font-size: inherit; 55 | overflow: initial; 56 | margin: 0; 57 | justify-content: space-between; 58 | text-transform: none; 59 | } 60 | 61 | .hlx-badge:focus, 62 | .hlx-badge:hover { 63 | --color: #888; 64 | } 65 | 66 | .hlx-badge:focus-visible { 67 | outline-style: solid; 68 | outline-width: .25em; 69 | } 70 | 71 | .hlx-badge > span { 72 | user-select: none; 73 | } 74 | 75 | .hlx-badge .hlx-open { 76 | box-sizing: border-box; 77 | position: relative; 78 | display: block; 79 | width: 22px; 80 | height: 22px; 81 | border: 2px solid; 82 | border-radius: 100px; 83 | margin-left: 16px; 84 | } 85 | 86 | .hlx-badge .hlx-open::after { 87 | content: ""; 88 | display: block; 89 | box-sizing: border-box; 90 | position: absolute; 91 | width: 6px; 92 | height: 6px; 93 | border-top: 2px solid; 94 | border-right: 2px solid; 95 | transform: rotate(-45deg); 96 | left: 6px; 97 | bottom: 5px; 98 | } 99 | 100 | .hlx-badge.hlx-testing { 101 | background-color: #fa0f00; 102 | color: #fff; 103 | } 104 | 105 | .hlx-popup { 106 | position: absolute; 107 | display: grid; 108 | grid-template: 109 | "header" min-content 110 | "content" 1fr; 111 | bottom: 6.5em; 112 | left: 50%; 113 | transform: translateX(-50%); 114 | max-height: calc(100vh - 100px - var(--nav-height, 100px)); 115 | max-width: calc(100vw - 2em); 116 | min-width: calc(300px - 2em); 117 | background-color: #444; 118 | border-radius: 16px; 119 | box-shadow: 0 0 10px #000; 120 | font-size: 12px; 121 | text-align: initial; 122 | white-space: initial; 123 | } 124 | 125 | .hlx-popup a:any-link { 126 | color: #eee; 127 | border: 2px solid; 128 | padding: 5px 12px; 129 | display: inline-block; 130 | border-radius: 20px; 131 | text-decoration: none; 132 | } 133 | 134 | .hlx-popup-header { 135 | display: grid; 136 | grid-area: header; 137 | grid-template: 138 | "label actions" 139 | "description actions" 140 | / 1fr min-content; 141 | background-color: #222; 142 | border-radius: 16px 16px 0 0; 143 | padding: 24px 16px; 144 | } 145 | 146 | .hlx-popup-items { 147 | overflow-y: auto; 148 | grid-area: content; 149 | scrollbar-gutter: stable; 150 | scrollbar-width: thin; 151 | } 152 | 153 | .hlx-popup-header-label { 154 | grid-area: label; 155 | } 156 | 157 | .hlx-popup-header-description { 158 | grid-area: description; 159 | } 160 | 161 | .hlx-popup-header-actions { 162 | grid-area: actions; 163 | display: flex; 164 | flex-direction: column; 165 | } 166 | 167 | .hlx-popup h4, .hlx-popup h5 { 168 | margin: 0; 169 | } 170 | 171 | .hlx-popup h4 { 172 | font-size: 16px; 173 | } 174 | 175 | .hlx-popup h5 { 176 | font-size: 14px; 177 | } 178 | 179 | 180 | .hlx-popup p { 181 | margin: 0; 182 | } 183 | 184 | .hlx-popup::before { 185 | content: ''; 186 | width: 0; 187 | height: 0; 188 | position: absolute; 189 | border-left: 15px solid transparent; 190 | border-right: 15px solid transparent; 191 | border-top: 15px solid #444; 192 | bottom: -15px; 193 | right: 50%; 194 | transform: translateX(50%); 195 | } 196 | 197 | .hlx-hidden { 198 | display: none; 199 | } 200 | 201 | .hlx-badge.is-active, 202 | .hlx-badge[aria-pressed="true"] { 203 | --color: #280; 204 | } 205 | 206 | .hlx-badge.is-inactive, 207 | .hlx-badge[aria-pressed="false"] { 208 | --color: #fa0f00; 209 | } 210 | 211 | .hlx-popup-item { 212 | display: grid; 213 | grid-template: 214 | "label actions" 215 | "description actions" 216 | / 1fr min-content; 217 | margin: 1em; 218 | padding: 1em; 219 | border-radius: 1em; 220 | gap: .5em 1em; 221 | } 222 | 223 | .hlx-popup-item-label { 224 | grid-area: label; 225 | white-space: nowrap; 226 | } 227 | 228 | .hlx-popup-item-description { 229 | grid-area: description; 230 | } 231 | 232 | .hlx-popup-item-actions { 233 | grid-area: actions; 234 | display: flex; 235 | flex-direction: column; 236 | } 237 | 238 | .hlx-popup-item.is-selected { 239 | background-color: #666; 240 | } 241 | 242 | .hlx-popup-item .hlx-button { 243 | flex: 0 0 auto; 244 | } 245 | 246 | @media (width >= 600px) { 247 | .hlx-highlight { 248 | --highlight-size: .75rem; 249 | } 250 | 251 | .hlx-preview-overlay { 252 | right: 1em; 253 | align-items: end; 254 | font-size: 1.25rem; 255 | } 256 | 257 | .hlx-popup { 258 | right: 0; 259 | left: auto; 260 | transform: none; 261 | min-width: 300px; 262 | bottom: 8em; 263 | } 264 | 265 | .hlx-popup::before { 266 | right: 26px; 267 | transform: none; 268 | } 269 | } 270 | 271 | @media (width >= 900px) { 272 | .hlx-highlight { 273 | --highlight-size: 1rem; 274 | } 275 | 276 | .hlx-preview-overlay { 277 | flex-flow: row wrap-reverse; 278 | justify-content: flex-end; 279 | font-size: 1.5rem; 280 | } 281 | 282 | .hlx-popup { 283 | bottom: 9em; 284 | } 285 | 286 | .hlx-popup::before { 287 | right: 32px; 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/ued.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | /* eslint-disable */ 13 | 14 | var storage = window.sessionStorage; 15 | 16 | function murmurhash3_32_gc(key, seed) { 17 | var remainder = key.length & 3; 18 | var bytes = key.length - remainder; 19 | var c1 = 0xcc9e2d51; 20 | var c2 = 0x1b873593; 21 | var h1 = seed; 22 | var k1; 23 | var h1b; 24 | var i = 0; 25 | while (i < bytes) { 26 | k1 = 27 | ((key.charCodeAt(i) & 0xff)) | 28 | ((key.charCodeAt(++i) & 0xff) << 8) | 29 | ((key.charCodeAt(++i) & 0xff) << 16) | 30 | ((key.charCodeAt(++i) & 0xff) << 24); 31 | ++i; 32 | k1 = ((((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16))) & 0xffffffff; 33 | k1 = (k1 << 15) | (k1 >>> 17); 34 | k1 = ((((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16))) & 0xffffffff; 35 | h1 ^= k1; 36 | h1 = (h1 << 13) | (h1 >>> 19); 37 | h1b = ((((h1 & 0xffff) * 5) + ((((h1 >>> 16) * 5) & 0xffff) << 16))) & 0xffffffff; 38 | h1 = (((h1b & 0xffff) + 0x6b64) + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16)); 39 | } 40 | k1 = 0; 41 | switch (remainder) { 42 | case 3: k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16; 43 | case 2: k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8; 44 | case 1: 45 | k1 ^= (key.charCodeAt(i) & 0xff); 46 | k1 = (((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; 47 | k1 = (k1 << 15) | (k1 >>> 17); 48 | k1 = (((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; 49 | h1 ^= k1; 50 | } 51 | h1 ^= key.length; 52 | h1 ^= h1 >>> 16; 53 | h1 = (((h1 & 0xffff) * 0x85ebca6b) + ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff; 54 | h1 ^= h1 >>> 13; 55 | h1 = ((((h1 & 0xffff) * 0xc2b2ae35) + ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16))) & 0xffffffff; 56 | h1 ^= h1 >>> 16; 57 | return h1 >>> 0; 58 | } 59 | 60 | var TOTAL_BUCKETS = 10000; 61 | function getBucket(saltedId) { 62 | var hash = murmurhash3_32_gc(saltedId, 0); 63 | var hashFixedBucket = Math.abs(hash) % TOTAL_BUCKETS; 64 | var bucket = hashFixedBucket / TOTAL_BUCKETS; 65 | return bucket; 66 | } 67 | function pickWithWeightsBucket(allocationPercentages, treatments, bucket) { 68 | var sum = allocationPercentages.reduce(function (partialSum, a) { return partialSum + a; }, 0); 69 | var partialSum = 0.0; 70 | for (var i = 0; i < treatments.length; i++) { 71 | partialSum += Number(allocationPercentages[i].toFixed(2)) / sum; 72 | if (bucket > partialSum) { 73 | continue; 74 | } 75 | return treatments[i]; 76 | } 77 | } 78 | function assignTreatmentByVisitor(experimentid, identityId, allocationPercentages, treatments) { 79 | var saltedId = experimentid + '.' + identityId; 80 | var bucketId = getBucket(saltedId); 81 | var treatmentId = pickWithWeightsBucket(allocationPercentages, treatments, bucketId); 82 | return { 83 | treatmentId: treatmentId, 84 | bucketId: bucketId 85 | }; 86 | } 87 | 88 | var LOCAL_STORAGE_KEY = 'unified-decisioning-experiments'; 89 | function assignTreatment(allocationPercentages, treatments) { 90 | var random = Math.random() * 100; 91 | var i = treatments.length; 92 | while (random > 0 && i > 0) { 93 | i -= 1; 94 | random -= +allocationPercentages[i]; 95 | } 96 | return treatments[i]; 97 | } 98 | function getLastExperimentTreatment(experimentId) { 99 | var experimentsStr = storage.getItem(LOCAL_STORAGE_KEY); 100 | if (experimentsStr) { 101 | var experiments = JSON.parse(experimentsStr); 102 | if (experiments[experimentId]) { 103 | return experiments[experimentId].treatment; 104 | } 105 | } 106 | return null; 107 | } 108 | function setLastExperimentTreatment(experimentId, treatment) { 109 | var experimentsStr = storage.getItem(LOCAL_STORAGE_KEY); 110 | var experiments = experimentsStr ? JSON.parse(experimentsStr) : {}; 111 | var now = new Date(); 112 | var expKeys = Object.keys(experiments); 113 | expKeys.forEach(function (key) { 114 | var date = new Date(experiments[key].date); 115 | if ((now.getTime() - date.getTime()) > (1000 * 86400 * 30)) { 116 | delete experiments[key]; 117 | } 118 | }); 119 | var date = now.toISOString().split('T')[0]; 120 | experiments[experimentId] = { treatment: treatment, date: date }; 121 | storage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(experiments)); 122 | } 123 | function assignTreatmentByDevice(experimentId, allocationPercentages, treatments) { 124 | var cachedTreatmentId = getLastExperimentTreatment(experimentId); 125 | var treatmentIdResponse; 126 | if (!cachedTreatmentId || !treatments.includes(cachedTreatmentId)) { 127 | var assignedTreatmentId = assignTreatment(allocationPercentages, treatments); 128 | setLastExperimentTreatment(experimentId, assignedTreatmentId); 129 | treatmentIdResponse = assignedTreatmentId; 130 | } 131 | else { 132 | treatmentIdResponse = cachedTreatmentId; 133 | } 134 | return { 135 | treatmentId: treatmentIdResponse 136 | }; 137 | } 138 | 139 | var RandomizationUnit = { 140 | VISITOR: 'VISITOR', 141 | DEVICE: 'DEVICE' 142 | }; 143 | function evaluateExperiment(context, experiment) { 144 | var experimentId = experiment.id, identityNamespace = experiment.identityNamespace, _a = experiment.randomizationUnit, randomizationUnit = _a === void 0 ? RandomizationUnit.VISITOR : _a; 145 | var identityMap = context.identityMap; 146 | var treatments = experiment.treatments.map(function (item) { return item.id; }); 147 | var allocationPercentages = experiment.treatments.map(function (item) { return item.allocationPercentage; }); 148 | var treatmentAssignment = null; 149 | switch (randomizationUnit) { 150 | case RandomizationUnit.VISITOR: { 151 | var identityId = identityMap[identityNamespace][0].id; 152 | treatmentAssignment = assignTreatmentByVisitor(experimentId, identityId, allocationPercentages, treatments); 153 | break; 154 | } 155 | case RandomizationUnit.DEVICE: { 156 | treatmentAssignment = assignTreatmentByDevice(experimentId, allocationPercentages, treatments); 157 | break; 158 | } 159 | default: 160 | throw new Error("Unknow randomization unit"); 161 | } 162 | var evaluationResponse = { 163 | experimentId: experimentId, 164 | hashedBucket: treatmentAssignment.bucketId, 165 | treatment: { 166 | id: treatmentAssignment.treatmentId 167 | } 168 | }; 169 | return evaluationResponse; 170 | } 171 | 172 | function traverseDecisionTree(decisionNodesMap, context, currentNodeId) { 173 | var _a = decisionNodesMap[currentNodeId], experiment = _a.experiment, type = _a.type; 174 | if (type === 'EXPERIMENTATION') { 175 | var treatment = evaluateExperiment(context, experiment).treatment; 176 | return [treatment]; 177 | } 178 | } 179 | function evaluateDecisionPolicy(decisionPolicy, context) { 180 | if (context.storage && context.storage instanceof Storage) { 181 | storage = context.storage; 182 | } 183 | var decisionNodesMap = {}; 184 | decisionPolicy.decisionNodes.forEach(function (item) { 185 | decisionNodesMap[item['id']] = item; 186 | }); 187 | var items = traverseDecisionTree(decisionNodesMap, context, decisionPolicy.rootDecisionNodeId); 188 | return { 189 | items: items 190 | }; 191 | } 192 | 193 | export const ued = { evaluateDecisionPolicy }; 194 | -------------------------------------------------------------------------------- /documentation/experiments.md: -------------------------------------------------------------------------------- 1 | With experiments (also called A/B tests) you can randomly serve different versions of your content to your end users to test out alternate experiences or validate conversion hypotheses. For instance you can: 2 | - compare how the wording in a hero block impacts the conversion on the call to action element 3 | - compare how 2 different implementations of a specific block impacts the overall performance, engagement and/or user conversion 4 | 5 | ## Set up 6 | 7 | The set up is pretty minimal. Once you've instrumented the experimentation plugin in your AEM website, you are essentially good to go. 8 | 9 | Just keep in mind that if you want to only target specific audiences for that experiment, you'll also need to set up the [audiences](./audiences.md) accordingly for your project. 10 | 11 | ### Custom options 12 | 13 | By default, the experiments feature looks at the `Experiment` metadata tags and `experiment` query parameter, but if this clashes with your existing codebase or doesn't feel intuitive to your authors, you can adjust this by passing new options to the plugin. 14 | 15 | For instance, here is an alternate configuration that would use `abtest` instead of `experiment`: 16 | ```js 17 | const { loadEager } = await import('../plugins/experimentation/src/index.js'); 18 | await loadEager(document, { 19 | experimentsMetaTag: 'abtest', 20 | experimentsQueryParameter: 'abtest', 21 | }, /* plugin execution context */); 22 | ``` 23 | 24 | ### Tracking custom conversion events 25 | 26 | By default, the engine will consider any `click` a conversion. If you want to be more granular in your tests, you have 2 options available: 27 | 1. leverage the `Experiment Conversion Name` property in the metadata 28 | 2. Use the https://github.com/adobe/aem-rum-conversion plugin and the experimentation engine will automatically detect its `Conversion Name` property. 29 | 30 | ## Authoring 31 | 32 | Once the above steps are done, your authors are ready to start using experiments for their experiences. 33 | This is done directly in the page metadata block: 34 | 35 | | Metadata | | 36 | |---------------------|--------------------------------------------------------------| 37 | | Experiment | Hero Test | 38 | | Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | 39 | 40 | The page that is instrumented is called the `control`, and the content variations are `variants` or `challengers`. 41 | Variants are evenly split by default, so the above would give us: 42 | - 25% for the control (the page that has the metadata) 43 | - 25% for each of the 3 variants that are defined 44 | 45 | If you want to control the split ratio, you can use: 46 | 47 | | Metadata | | 48 | |---------------------|--------------------------------------------------------------| 49 | | Experiment | Hero Test | 50 | | Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | 51 | | Experiment Split | 10, 20, 30 | 52 | 53 | This would give us the following split: 54 | 55 | - 10% on variant 1 56 | - 20% on variant 2 57 | - 30% on variant 3 58 | - 40% on the control (i.e 100% - 10% - 20% - 30% = 40%) 59 | 60 | A `30, 30, 40` split, would respectively give us: 61 | 62 | - 30% on variant 1 63 | - 30% on variant 2 64 | - 40% on variant 3 65 | - 0% on the control (i.e 100% - 30% - 30% - 40% = 0%) 66 | 67 | which would essentially disable the control page. 68 | 69 | If you need to be really granular, decimal numbers are also supported, like `33.34, 33.33, 33.33`. 70 | 71 | #### Code-level experiments 72 | 73 | Note that the above assumes you have different content variants to serve, but if you want to run a pure code-based A/B Test, this is also achievable via: 74 | 75 | | Metadata | | 76 | |---------------------|-----------| 77 | | Experiment | Hero Test | 78 | | Experiment Variants | 2 | 79 | 80 | This will create just 2 variants, without touching the content, and you'll be able to target those based on the `experiment-hero-test` and `variant-control`/`variant-challenger-1`/`variant-challenger-2` CSS classes that will be set on the `` element. 81 | 82 | 83 | #### Audience-based experiments 84 | 85 | If you wanted to additionally restrict the experiments to specific audiences, so that for instance your experiment is only run on iPad or on iPhone, you'd leverage the [audiences](./audiences.md) feature and use the following metadata: 86 | 87 | | Metadata | | 88 | |---------------------|--------------------------------------------------------------| 89 | | Experiment | Hero Test | 90 | | Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | 91 | | Experiment Audience | iPad, iPhone | 92 | 93 | If any of the listed audiences is resolved, then the experiment will run and the matching content will be served. The list is essentially treated as an "or". 94 | If you needed both audiences to be resolved (i.e. treated as "and"), for say a "US" audience and the "iPad" audience, you'd define a new custom "us-ipad" audience in your project that checks for both conditions and use that in the metadata instead. 95 | 96 | #### Time bound experiments 97 | 98 | You can also specify start and end dates, as well as toggle the experiment status. 99 | 100 | | Metadata | | 101 | |-----------------------|--------------------------------------------------------------| 102 | | Experiment | Hero Test | 103 | | Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | 104 | | Experiment Status | Active | 105 | | Experiment Start Date | 2024-01-01 | 106 | | Experiment End Date | 2024-03-31 | 107 | 108 | The status defaults to `Active`, and supports `Active`/`True`/`On` as well as `Inactive`/`False`/`Off`. 109 | Start and end dates are in the flexible JS [Date Time String Format](https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-date-time-string-format). 110 | 111 | ##### Date only 112 | You can use start and end dates like `2024-01-31` or `2024/01/31`. If a start and/or end date is specified, the experiment will _only_ run if the visitor's local time matches the given date. 113 | 114 | ##### Date & time 115 | You can also use time-specific dates like `2024-01-31T13:37` or `2024/01/31 1:37 pm`. If you wish to have a globally consistent test, you can enforce a specific timezone so your experiment activates when, say, it's 2am GMT+1 by using `2024/1/31 2:00 pm GMT+1` or similar notations. 116 | 117 | ### Simulation 118 | 119 | Once all of this is set up, authors will have access to an overlay on `localhost` and on the stage environments (i.e. `*.hlx.stage`) that lets them see what experiment and variants have been configured for the page and switch between each to visualize the content variations accordingly. 120 | 121 | ![audience overlay](./images/experiments-overlay.png) 122 | 123 | The simulation capabilities leverage the `audience` query parameter that is appended to the URL and forcibly let you see the specific content variant. 124 | 125 | ### Inline Reporting 126 | 127 | AEM Experiments performance is automatically tracked via RUM data, and can be reported directly in the overlay pill UI. Since the RUM data is not public, you'll need to obtain a **domain key** for your website and configure the pill accordingly for the data to show up. 128 | 129 | 1. Generate a domain key for your site using https://aemcs-workspace.adobe.com/rum/generate-domain-key (make sure to use exactly the same domain name that you used in your project config for go-live) 130 | 2. Click the ⚙️ icon in the pill header, and paste the provided domain key in the popup dialog 131 | - alternatively, you can also just run `window.localStorage.setItem('aem-domainkey', )` in the JS console 132 | 133 | ## Development 134 | 135 | To help developers in designing variants for each experiment, when an experiment is running on the page it will automatically add new CSS classes named `experiment-` and `variant-` to the `` element, i.e. `experiment-hero variant-fullpage`. 136 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AEM Edge Delivery Services Experimentation 2 | 3 | The AEM Experimentation plugin helps you quickly set up experimentation and segmentation on your AEM project. 4 | It is currently available to customers in collaboration with AEM Engineering via co-innovation VIP Projects. 5 | To implement experimentation or personalization use-cases, please reach out to the AEM Engineering team in the Slack channel dedicated to your project. 6 | 7 | ## Features 8 | 9 | The AEM Experimentation plugin supports: 10 | - :lock: privacy-first, as it doesn't use any, nor persists any, end-user data that could lead to their identification. No end-user opt-in nor cookie consent is required when using the default configuration that uses [AEM Edge Delivery Services Real User Monitoring](https://github.com/adobe/helix-rum-js/).* 11 | - :busts_in_silhouette: serving different content variations to different audiences, including custom audience definitions for your project that can be either resolved directly in-browser or against a trusted backend API. 12 | - :money_with_wings: serving different content variations based on marketing campaigns you are running, so that you can easily track email and/or social campaigns. 13 | - :chart_with_upwards_trend: running A/B test experiments on a set of variants to measure and improve the conversion on your site. This works particularly with our :chart: [RUM conversion tracking plugin](https://github.com/adobe/franklin-rum-conversion). 14 | - :rocket: easy simulation of each experience and basic reporting leveraging in-page overlays. 15 | 16 | \* Bringing additional marketing technology such as visitor-based analytics or personalization to a project will cancel this privacy-first principle. 17 | 18 | ## Installation 19 | 20 | :warning: If you are instrumenting a new site, please switch to [v2](https://github.com/adobe/aem-experimentation/tree/v2). 21 | 22 | Add the plugin to your AEM project by running: 23 | ```sh 24 | git subtree add --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git main 25 | ``` 26 | 27 | If you later want to pull the latest changes and update your local copy of the plugin 28 | ```sh 29 | git subtree pull --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git main 30 | ``` 31 | 32 | If you prefer using `https` links you'd replace `git@github.com:adobe/aem-experimentation.git` in the above commands by `https://github.com/adobe/aem-experimentation.git`. 33 | 34 | If the `subtree pull` command is failing with an error like: 35 | ``` 36 | fatal: can't squash-merge: 'plugins/experimentation' was never added 37 | ``` 38 | you can just delete the folder and re-add the plugin via the `git subtree add` command above. 39 | 40 | ## Project instrumentation 41 | 42 | ### On top of a regular boilerplate project 43 | 44 | Typically, you'd know you don't have the plugin system if you don't see a reference to `window.hlx.plugins` in your `scripts.js`. In that case, you can still manually instrument this plugin in your project by falling back to a more manual instrumentation. To properly connect and configure the plugin for your project, you'll need to edit your `scripts.js` in your AEM project and add the following: 45 | 46 | 1. at the start of the file: 47 | ```js 48 | const AUDIENCES = { 49 | mobile: () => window.innerWidth < 600, 50 | desktop: () => window.innerWidth >= 600, 51 | // define your custom audiences here as needed 52 | }; 53 | 54 | /** 55 | * Gets all the metadata elements that are in the given scope. 56 | * @param {String} scope The scope/prefix for the metadata 57 | * @returns an array of HTMLElement nodes that match the given scope 58 | */ 59 | export function getAllMetadata(scope) { 60 | return [...document.head.querySelectorAll(`meta[property^="${scope}:"],meta[name^="${scope}-"]`)] 61 | .reduce((res, meta) => { 62 | const id = toClassName(meta.name 63 | ? meta.name.substring(scope.length + 1) 64 | : meta.getAttribute('property').split(':')[1]); 65 | res[id] = meta.getAttribute('content'); 66 | return res; 67 | }, {}); 68 | } 69 | ``` 70 | 2. if this is the first plugin you add to your project, you'll also need to add: 71 | ```js 72 | // Define an execution context 73 | const pluginContext = { 74 | getAllMetadata, 75 | getMetadata, 76 | loadCSS, 77 | loadScript, 78 | sampleRUM, 79 | toCamelCase, 80 | toClassName, 81 | }; 82 | ``` 83 | And make sure to import any missing/undefined methods from `aem.js`/`lib-franklin.js` at the very top of the file: 84 | ```js 85 | import { 86 | ... 87 | getMetadata, 88 | loadScript, 89 | toCamelCase, 90 | toClassName, 91 | } from './aem.js'; 92 | ``` 93 | 3. Early in the `loadEager` method you'll need to add: 94 | ```js 95 | async function loadEager(doc) { 96 | … 97 | // Add below snippet early in the eager phase 98 | if (getMetadata('experiment') 99 | || Object.keys(getAllMetadata('campaign')).length 100 | || Object.keys(getAllMetadata('audience')).length) { 101 | // eslint-disable-next-line import/no-relative-packages 102 | const { loadEager: runEager } = await import('../plugins/experimentation/src/index.js'); 103 | await runEager(document, { audiences: AUDIENCES }, pluginContext); 104 | } 105 | … 106 | } 107 | ``` 108 | This needs to be done as early as possible since this will be blocking the eager phase and impacting your LCP, so we want this to execute as soon as possible. 109 | 4. Finally at the end of the `loadLazy` method you'll have to add: 110 | ```js 111 | async function loadLazy(doc) { 112 | … 113 | // Add below snippet at the end of the lazy phase 114 | if ((getMetadata('experiment') 115 | || Object.keys(getAllMetadata('campaign')).length 116 | || Object.keys(getAllMetadata('audience')).length)) { 117 | // eslint-disable-next-line import/no-relative-packages 118 | const { loadLazy: runLazy } = await import('../plugins/experimentation/src/index.js'); 119 | await runLazy(document, { audiences: AUDIENCES }, pluginContext); 120 | } 121 | } 122 | ``` 123 | This is mostly used for the authoring overlay, and as such isn't essential to the page rendering, so having it at the end of the lazy phase is good enough. 124 | 125 | ### On top of the plugin system (deprecated) 126 | 127 | The easiest way to add the plugin is if your project is set up with the plugin system extension in the boilerplate. 128 | You'll know you have it if `window.hlx.plugins` is defined on your page. 129 | 130 | If you don't have it, you can follow the proposal in https://github.com/adobe/aem-lib/pull/23 and https://github.com/adobe/aem-boilerplate/pull/275 and apply the changes to your `aem.js`/`lib-franklin.js` and `scripts.js`. 131 | 132 | Once you have confirmed this, you'll need to edit your `scripts.js` in your AEM project and add the following at the start of the file: 133 | ```js 134 | const AUDIENCES = { 135 | mobile: () => window.innerWidth < 600, 136 | desktop: () => window.innerWidth >= 600, 137 | // define your custom audiences here as needed 138 | }; 139 | 140 | window.hlx.plugins.add('experimentation', { 141 | condition: () => getMetadata('experiment') 142 | || Object.keys(getAllMetadata('campaign')).length 143 | || Object.keys(getAllMetadata('audience')).length, 144 | options: { audiences: AUDIENCES }, 145 | url: '/plugins/experimentation/src/index.js', 146 | }); 147 | ``` 148 | 149 | ### Custom options 150 | 151 | There are various aspects of the plugin that you can configure via options you are passing to the 2 main methods above (`runEager`/`runLazy`). 152 | You have already seen the `audiences` option in the examples above, but here is the full list we support: 153 | 154 | ```js 155 | runEager.call(document, { 156 | // Overrides the base path if the plugin was installed in a sub-directory 157 | basePath: '', 158 | 159 | // Lets you configure the prod environment. 160 | // (prod environments do not get the pill overlay) 161 | prodHost: 'www.my-website.com', 162 | // if you have several, or need more complex logic to toggle pill overlay, you can use 163 | isProd: () => window.location.hostname.endsWith('hlx.page') 164 | || window.location.hostname === ('localhost'), 165 | 166 | /* Generic properties */ 167 | // RUM sampling rate on regular AEM pages is 1 out of 100 page views 168 | // but we increase this by default for audiences, campaigns and experiments 169 | // to 1 out of 10 page views so we can collect metrics faster of the relative 170 | // short durations of those campaigns/experiments 171 | rumSamplingRate: 10, 172 | 173 | // the storage type used to persist data between page views 174 | // (for instance to remember what variant in an experiment the user was served) 175 | storage: window.sessionStorage, 176 | 177 | /* Audiences related properties */ 178 | // See more details on the dedicated Audiences page linked below 179 | audiences: {}, 180 | audiencesMetaTagPrefix: 'audience', 181 | audiencesQueryParameter: 'audience', 182 | 183 | /* Campaigns related properties */ 184 | // See more details on the dedicated Campaigns page linked below 185 | campaignsMetaTagPrefix: 'campaign', 186 | campaignsQueryParameter: 'campaign', 187 | 188 | /* Experimentation related properties */ 189 | // See more details on the dedicated Experiments page linked below 190 | experimentsMetaTag: 'experiment', 191 | experimentsQueryParameter: 'experiment', 192 | }, pluginContext); 193 | ``` 194 | 195 | For detailed implementation instructions on the different features, please read the dedicated pages we have on those topics: 196 | - [Audiences](/documentation/audiences.md) 197 | - [Campaigns](/documentation/campaigns.md) 198 | - [Experiments](/documentation/experiments.md) 199 | 200 | ## Extensibility & integrations 201 | 202 | The experimentation plugin exposes APIs that allow you to integrate with analytics platforms and other 3rd-party libraries. 203 | 204 | ### Available APIs 205 | 206 | #### Global Objects 207 | 208 | Access experiment data through global JavaScript objects: 209 | 210 | ```javascript 211 | // Current implementation 212 | const experiment = window.hlx.experiment; 213 | const audience = window.hlx.audience; 214 | const campaign = window.hlx.campaign; 215 | ``` 216 | 217 | ### Integration Examples 218 | 219 | #### Adobe Analytics, Target & AJO Integration 220 | 221 | For Adobe Analytics, Target, and Adobe Journey Optimizer integration: 222 | 223 | ```javascript 224 | // Check if experiment is running 225 | if (window.hlx.experiment) { 226 | // Option 1: Adobe Client Data Layer (works with all Adobe products via Tags) 227 | window.adobeDataLayer = window.adobeDataLayer || []; 228 | window.adobeDataLayer.push({ 229 | event: 'experiment-applied', 230 | experiment: { 231 | id: window.hlx.experiment.id, 232 | variant: window.hlx.experiment.selectedVariant 233 | } 234 | }); 235 | 236 | // Option 2: Modern Web SDK integration (AEP + Analytics) 237 | if (window.alloy) { 238 | alloy("sendEvent", { 239 | xdm: { 240 | eventType: "decisioning.propositionDisplay", 241 | timestamp: new Date().toISOString(), 242 | _experience: { 243 | decisioning: { 244 | propositions: [{ 245 | id: window.hlx.experiment.id, 246 | scope: "page", 247 | items: [{ 248 | id: window.hlx.experiment.selectedVariant, 249 | schema: "https://ns.adobe.com/personalization/default-content-item" 250 | }] 251 | }], 252 | propositionEventType: { 253 | display: 1 254 | } 255 | } 256 | } 257 | }, 258 | data: { 259 | __adobe: { 260 | analytics: { 261 | eVar1: window.hlx.experiment.id, 262 | eVar2: window.hlx.experiment.selectedVariant, 263 | events: "event1" 264 | } 265 | }, 266 | experiment: { 267 | id: window.hlx.experiment.id, 268 | variant: window.hlx.experiment.selectedVariant, 269 | timestamp: Date.now() 270 | } 271 | } 272 | }); 273 | } 274 | } 275 | ``` 276 | 277 | > **Choose your approach**: 278 | > - **Option 1**: Works with all Adobe products via Tags (simplest) 279 | > - **Option 2**: Direct Web SDK with full AEP + Analytics integration 280 | 281 | #### Google Tag Manager / Google Analytics 282 | 283 | ```javascript 284 | if (window.hlx.experiment) { 285 | window.dataLayer = window.dataLayer || []; 286 | window.dataLayer.push({ 287 | event: 'experiment_view', 288 | experiment_id: window.hlx.experiment.id, 289 | experiment_variant: window.hlx.experiment.selectedVariant 290 | }); 291 | } 292 | ``` 293 | 294 | #### Tealium 295 | 296 | ```javascript 297 | // Example from UPS implementation 298 | if (window.hlx.experiment) { 299 | window.utag_data = window.utag_data || {}; 300 | window.utag_data.cms_experiment = `${window.hlx.experiment.id}:${window.hlx.experiment.selectedVariant}`; 301 | } 302 | ``` 303 | 304 | ### Implementation Notes 305 | 306 | - **Customer responsibility**: You implement the analytics integration in your project code 307 | - **Runtime only**: Data is available at runtime - no backend integration provided 308 | - **Project-specific**: Integration depends on your analytics setup and project structure 309 | - **Existing analytics required**: Your analytics platform must already be implemented 310 | 311 | ### Complete Reference 312 | 313 | #### Experiment Config Structure 314 | 315 | Here's the complete experiment config structure available in `window.hlx.experiment`: 316 | 317 | ```javascript 318 | { 319 | id: "experiment-name", 320 | selectedVariant: "challenger-1", 321 | status: "active", 322 | variantNames: ["control", "challenger-1"], 323 | audiences: ["mobile", "desktop"], 324 | resolvedAudiences: ["mobile"], 325 | run: true, 326 | variants: { 327 | control: { percentageSplit: "0.5", pages: ["/current"], label: "Control" }, 328 | "challenger-1": { percentageSplit: "0.5", pages: ["/variant"], label: "Challenger 1" } 329 | } 330 | } 331 | ``` 332 | 333 | > **Note**: For analytics integration, you typically only need `id` and `selectedVariant`. The full config structure above is available if you need detailed experiment settings for custom logic. 334 | -------------------------------------------------------------------------------- /src/preview.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | const DOMAIN_KEY_NAME = 'aem-domainkey'; 14 | 15 | class AemExperimentationBar extends HTMLElement { 16 | connectedCallback() { 17 | // Create a shadow root 18 | const shadow = this.attachShadow({ mode: 'open' }); 19 | 20 | const cssPath = new URL(new Error().stack.split('\n')[2].match(/[a-z]+?:\/\/.*?\/[^:]+/)[0]).pathname.replace('preview.js', 'preview.css'); 21 | const link = document.createElement('link'); 22 | link.rel = 'stylesheet'; 23 | link.href = cssPath; 24 | link.onload = () => { 25 | shadow.querySelector('.hlx-preview-overlay').removeAttribute('hidden'); 26 | }; 27 | shadow.append(link); 28 | 29 | const el = document.createElement('div'); 30 | el.className = 'hlx-preview-overlay'; 31 | el.setAttribute('hidden', true); 32 | shadow.append(el); 33 | } 34 | } 35 | customElements.define('aem-experimentation-bar', AemExperimentationBar); 36 | 37 | function createPreviewOverlay() { 38 | const overlay = document.createElement('aem-experimentation-bar'); 39 | return overlay; 40 | } 41 | 42 | function getOverlay() { 43 | let overlay = document.querySelector('aem-experimentation-bar')?.shadowRoot.children[1]; 44 | if (!overlay) { 45 | const el = createPreviewOverlay(); 46 | document.body.append(el); 47 | [, overlay] = el.shadowRoot.children; 48 | } 49 | return overlay; 50 | } 51 | 52 | function createButton(label) { 53 | const button = document.createElement('button'); 54 | button.className = 'hlx-badge'; 55 | const text = document.createElement('span'); 56 | text.textContent = label; 57 | button.append(text); 58 | return button; 59 | } 60 | 61 | function createPopupItem(item) { 62 | const div = document.createElement('div'); 63 | div.className = `hlx-popup-item${item.isSelected ? ' is-selected' : ''}`; 64 | 65 | const label = document.createElement('h5'); 66 | label.className = 'hlx-popup-item-label'; 67 | label.textContent = typeof item === 'object' ? item.label : item; 68 | div.appendChild(label); 69 | 70 | if (item.description) { 71 | const description = document.createElement('div'); 72 | description.className = 'hlx-popup-item-description'; 73 | description.textContent = item.description; 74 | div.appendChild(description); 75 | } 76 | 77 | // Create performance placeholder for experiment variants 78 | const performance = document.createElement('p'); 79 | performance.className = 'performance'; 80 | div.appendChild(performance); 81 | 82 | if (typeof item === 'object' && item.actions && item.actions.length) { 83 | const actionsDiv = document.createElement('div'); 84 | actionsDiv.className = 'hlx-popup-item-actions'; 85 | 86 | item.actions.forEach((action) => { 87 | const buttonDiv = document.createElement('div'); 88 | buttonDiv.className = 'hlx-button'; 89 | 90 | const link = document.createElement('a'); 91 | link.href = action.href || '#'; 92 | link.textContent = action.label; 93 | 94 | if (action.onclick) { 95 | link.addEventListener('click', action.onclick); 96 | } 97 | 98 | buttonDiv.appendChild(link); 99 | actionsDiv.appendChild(buttonDiv); 100 | }); 101 | 102 | div.appendChild(actionsDiv); 103 | } 104 | 105 | const buttons = [...div.querySelectorAll('.hlx-button a')]; 106 | item.actions?.forEach((action, index) => { 107 | if (action.onclick) { 108 | buttons[index].addEventListener('click', action.onclick); 109 | } 110 | }); 111 | return div; 112 | } 113 | 114 | function createPopupDialog(header, items = []) { 115 | const popup = document.createElement('div'); 116 | popup.className = 'hlx-popup hlx-hidden'; 117 | 118 | const headerDiv = document.createElement('div'); 119 | headerDiv.className = 'hlx-popup-header'; 120 | 121 | const headerLabel = document.createElement('h5'); 122 | headerLabel.className = 'hlx-popup-header-label'; 123 | headerLabel.textContent = typeof header === 'object' ? header.label : header; 124 | headerDiv.appendChild(headerLabel); 125 | 126 | if (header.description) { 127 | const headerDescription = document.createElement('div'); 128 | headerDescription.className = 'hlx-popup-header-description'; 129 | if (typeof header.description === 'string') { 130 | headerDescription.textContent = header.description; 131 | } else { 132 | headerDescription.appendChild(header.description); 133 | } 134 | headerDiv.appendChild(headerDescription); 135 | } 136 | 137 | if (typeof header === 'object' && header.actions && header.actions.length) { 138 | const headerActions = document.createElement('div'); 139 | headerActions.className = 'hlx-popup-header-actions'; 140 | 141 | header.actions.forEach((action) => { 142 | const buttonDiv = document.createElement('div'); 143 | buttonDiv.className = 'hlx-button'; 144 | 145 | const link = document.createElement('a'); 146 | link.href = action.href || '#'; 147 | link.textContent = action.label; 148 | 149 | if (action.onclick) { 150 | link.addEventListener('click', action.onclick); 151 | } 152 | 153 | buttonDiv.appendChild(link); 154 | headerActions.appendChild(buttonDiv); 155 | }); 156 | 157 | headerDiv.appendChild(headerActions); 158 | } 159 | 160 | popup.appendChild(headerDiv); 161 | 162 | const itemsDiv = document.createElement('div'); 163 | itemsDiv.className = 'hlx-popup-items'; 164 | popup.appendChild(itemsDiv); 165 | 166 | const list = popup.querySelector('.hlx-popup-items'); 167 | items.forEach((item) => { 168 | list.append(createPopupItem(item)); 169 | }); 170 | const buttons = [...popup.querySelectorAll('.hlx-popup-header-actions .hlx-button a')]; 171 | header.actions?.forEach((action, index) => { 172 | if (action.onclick) { 173 | buttons[index].addEventListener('click', action.onclick); 174 | } 175 | }); 176 | return popup; 177 | } 178 | 179 | function createPopupButton(label, header, items) { 180 | const button = createButton(label); 181 | const popup = createPopupDialog(header, items); 182 | const openSpan = document.createElement('span'); 183 | openSpan.className = 'hlx-open'; 184 | button.appendChild(openSpan); 185 | button.append(popup); 186 | button.addEventListener('click', () => { 187 | popup.classList.toggle('hlx-hidden'); 188 | }); 189 | return button; 190 | } 191 | 192 | // eslint-disable-next-line no-unused-vars 193 | function createToggleButton(label) { 194 | const button = document.createElement('div'); 195 | button.className = 'hlx-badge'; 196 | button.role = 'button'; 197 | button.setAttribute('aria-pressed', false); 198 | button.setAttribute('tabindex', 0); 199 | const text = document.createElement('span'); 200 | text.textContent = label; 201 | button.append(text); 202 | button.addEventListener('click', () => { 203 | button.setAttribute('aria-pressed', button.getAttribute('aria-pressed') === 'false'); 204 | }); 205 | return button; 206 | } 207 | 208 | const percentformat = new Intl.NumberFormat('en-US', { style: 'percent', maximumSignificantDigits: 3 }); 209 | const countformat = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 2 }); 210 | const significanceformat = { 211 | format: (value) => { 212 | if (value < 0.005) { 213 | return 'highly significant'; 214 | } 215 | if (value < 0.05) { 216 | return 'significant'; 217 | } 218 | if (value < 0.1) { 219 | return 'marginally significant'; 220 | } 221 | return 'not significant'; 222 | }, 223 | }; 224 | const bigcountformat = { 225 | format: (value) => { 226 | if (value > 1000000) { 227 | return `${countformat.format(value / 1000000)}M`; 228 | } 229 | if (value > 1000) { 230 | return `${countformat.format(value / 1000)}K`; 231 | } 232 | return countformat.format(value); 233 | }, 234 | }; 235 | 236 | function createVariant(experiment, variantName, config, options) { 237 | const selectedVariant = config?.selectedVariant || config?.variantNames[0]; 238 | const variant = config.variants[variantName]; 239 | const split = variant.percentageSplit; 240 | const percentage = percentformat.format(split); 241 | 242 | const experimentURL = new URL(window.location.href); 243 | // this will retain other query params such as ?rum=on 244 | experimentURL.searchParams.set(options.experimentsQueryParameter, `${experiment}/${variantName}`); 245 | 246 | return { 247 | label: variantName, 248 | description: `${variant.label} (${percentage} split)`, 249 | actions: [{ label: 'Simulate', href: experimentURL.href }], 250 | isSelected: selectedVariant === variantName, 251 | }; 252 | } 253 | 254 | async function fetchRumData(experiment, options) { 255 | if (!options.domainKey) { 256 | // eslint-disable-next-line no-console 257 | console.warn('Cannot show RUM data. No `domainKey` configured.'); 258 | return null; 259 | } 260 | if (!options.prodHost && (typeof options.isProd !== 'function' || !options.isProd())) { 261 | // eslint-disable-next-line no-console 262 | console.warn('Cannot show RUM data. No `prodHost` configured or custom `isProd` method provided.'); 263 | return null; 264 | } 265 | 266 | // the query is a bit slow, so I'm only fetching the results when the popup is opened 267 | const resultsURL = new URL('https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-experiments'); 268 | // restrict results to the production host, this also reduces query cost 269 | if (typeof options.isProd === 'function' && options.isProd()) { 270 | resultsURL.searchParams.set('url', window.location.host); 271 | } else if (options.prodHost) { 272 | resultsURL.searchParams.set('url', options.prodHost); 273 | } 274 | resultsURL.searchParams.set('domainkey', options.domainKey); 275 | resultsURL.searchParams.set('experiment', experiment); 276 | resultsURL.searchParams.set('conversioncheckpoint', options.conversionName); 277 | 278 | const response = await fetch(resultsURL.href); 279 | if (!response.ok) { 280 | return null; 281 | } 282 | 283 | const { results } = await response.json(); 284 | const { data } = results; 285 | if (!data.length) { 286 | return null; 287 | } 288 | 289 | const numberify = (obj) => Object.entries(obj).reduce((o, [k, v]) => { 290 | o[k] = Number.parseFloat(v); 291 | o[k] = Number.isNaN(o[k]) ? v : o[k]; 292 | return o; 293 | }, {}); 294 | 295 | const variantsAsNums = data.map(numberify); 296 | const totals = Object.entries( 297 | variantsAsNums.reduce((o, v) => { 298 | Object.entries(v).forEach(([k, val]) => { 299 | if (typeof val === 'number' && Number.isInteger(val) && k.startsWith('variant_')) { 300 | o[k] = (o[k] || 0) + val; 301 | } else if (typeof val === 'number' && Number.isInteger(val) && k.startsWith('control_')) { 302 | o[k] = val; 303 | } 304 | }); 305 | return o; 306 | }, {}), 307 | ).reduce((o, [k, v]) => { 308 | o[k] = v; 309 | const vkey = k.replace(/^(variant|control)_/, 'variant_'); 310 | const ckey = k.replace(/^(variant|control)_/, 'control_'); 311 | const tkey = k.replace(/^(variant|control)_/, 'total_'); 312 | if (!Number.isNaN(o[ckey]) && !Number.isNaN(o[vkey])) { 313 | o[tkey] = o[ckey] + o[vkey]; 314 | } 315 | return o; 316 | }, {}); 317 | const richVariants = variantsAsNums 318 | .map((v) => ({ 319 | ...v, 320 | allocation_rate: v.variant_experimentations / totals.total_experimentations, 321 | })) 322 | .reduce((o, v) => { 323 | const variantName = v.variant; 324 | o[variantName] = v; 325 | return o; 326 | }, { 327 | control: { 328 | variant: 'control', 329 | ...Object.entries(variantsAsNums[0]).reduce((k, v) => { 330 | const [key, val] = v; 331 | if (key.startsWith('control_')) { 332 | k[key.replace(/^control_/, 'variant_')] = val; 333 | } 334 | return k; 335 | }, {}), 336 | }, 337 | }); 338 | const winner = variantsAsNums.reduce((w, v) => { 339 | if (v.variant_conversion_rate > w.conversion_rate && v.p_value < 0.05) { 340 | w.conversion_rate = v.variant_conversion_rate; 341 | w.p_value = v.p_value; 342 | w.variant = v.variant; 343 | } 344 | return w; 345 | }, { variant: 'control', p_value: 1, conversion_rate: 0 }); 346 | 347 | return { 348 | richVariants, 349 | totals, 350 | variantsAsNums, 351 | winner, 352 | }; 353 | } 354 | 355 | function populatePerformanceMetrics(div, config, { 356 | richVariants, totals, variantsAsNums, winner, 357 | }, conversionName = 'click') { 358 | const summary = div.querySelector('.hlx-info'); 359 | summary.textContent = `Showing results for ${bigcountformat.format(totals.total_experimentations)} visits and ${bigcountformat.format(totals.total_conversions)} conversions: `; 360 | 361 | if (totals.total_conversion_events < 500 && winner.p_value > 0.05) { 362 | summary.textContent += ` not yet enough data to determine a winner. Keep going until you get ${bigcountformat.format((500 * totals.total_experimentations) / totals.total_conversion_events)} visits.`; 363 | } else if (winner.p_value > 0.05) { 364 | summary.appendChild(document.createTextNode(' no significant difference between variants. In doubt, stick with ')); 365 | const noSignificanceControlElement = document.createElement('code'); 366 | noSignificanceControlElement.textContent = 'control'; 367 | summary.appendChild(noSignificanceControlElement); 368 | summary.appendChild(document.createTextNode('.')); 369 | } else if (winner.variant === 'control') { 370 | summary.appendChild(document.createTextNode(' Stick with ')); 371 | const controlWinnerElement = document.createElement('code'); 372 | controlWinnerElement.textContent = 'control'; 373 | summary.appendChild(controlWinnerElement); 374 | summary.appendChild(document.createTextNode('. No variant is better than the control.')); 375 | } else { 376 | summary.appendChild(document.createTextNode(' ')); 377 | const variantWinnerElement = document.createElement('code'); 378 | variantWinnerElement.textContent = winner.variant; 379 | summary.appendChild(variantWinnerElement); 380 | summary.appendChild(document.createTextNode(' is the winner.')); 381 | } 382 | 383 | config.variantNames.forEach((variantName, index) => { 384 | const variantDiv = document.querySelector('aem-experimentation-bar')?.shadowRoot.querySelectorAll('.hlx-popup-item')[index]; 385 | const percentage = variantDiv.querySelector('.percentage'); 386 | percentage.innerHTML = ''; 387 | 388 | const eventsSpan = document.createElement('span'); 389 | eventsSpan.title = `${countformat.format(richVariants[variantName].variant_conversion_events)} real events`; 390 | eventsSpan.textContent = `${bigcountformat.format(richVariants[variantName].variant_conversions)} ${conversionName} events`; 391 | percentage.appendChild(eventsSpan); 392 | percentage.appendChild(document.createTextNode(' / ')); 393 | 394 | const visitsSpan = document.createElement('span'); 395 | visitsSpan.title = `${countformat.format(richVariants[variantName].variant_experimentation_events)} real events`; 396 | visitsSpan.textContent = `${bigcountformat.format(richVariants[variantName].variant_experimentations)} visits`; 397 | percentage.appendChild(visitsSpan); 398 | percentage.appendChild(document.createTextNode(' ')); 399 | 400 | const splitSpan = document.createElement('span'); 401 | splitSpan.textContent = `(${percentformat.format(richVariants[variantName].variant_experimentations / totals.total_experimentations)} split)`; 402 | percentage.appendChild(splitSpan); 403 | }); 404 | 405 | variantsAsNums.forEach((result) => { 406 | const variant = document.querySelector('aem-experimentation-bar')?.shadowRoot.querySelectorAll('.hlx-popup-item')[config.variantNames.indexOf(result.variant)]; 407 | if (variant) { 408 | const performance = variant.querySelector('.performance'); 409 | performance.innerHTML = ''; 410 | 411 | const conversionSpan = document.createElement('span'); 412 | conversionSpan.textContent = `${conversionName} conversion rate: ${percentformat.format(result.variant_conversion_rate)}`; 413 | performance.appendChild(conversionSpan); 414 | performance.appendChild(document.createTextNode(' ')); 415 | 416 | const vsSpan = document.createElement('span'); 417 | vsSpan.textContent = `vs. ${percentformat.format(result.control_conversion_rate)}`; 418 | performance.appendChild(vsSpan); 419 | performance.appendChild(document.createTextNode(' ')); 420 | 421 | const significanceSpan = document.createElement('span'); 422 | significanceSpan.title = `p value: ${result.p_value}`; 423 | const significanceText = significanceformat.format(result.p_value); 424 | significanceSpan.className = `significance ${significanceText.replace(/ /, '-')}`; 425 | significanceSpan.textContent = significanceText; 426 | performance.appendChild(significanceSpan); 427 | } 428 | }); 429 | } 430 | 431 | /** 432 | * Create Badge if a Page is enlisted in a AEM Experiment 433 | * @return {Object} returns a badge or empty string 434 | */ 435 | async function decorateExperimentPill(overlay, options, context) { 436 | const config = window?.hlx?.experiment; 437 | const experiment = context.toClassName(context.getMetadata(options.experimentsMetaTag)); 438 | if (!experiment || !config) { 439 | return; 440 | } 441 | // eslint-disable-next-line no-console 442 | console.log('preview experiment', experiment); 443 | 444 | const domainKey = window.localStorage.getItem(DOMAIN_KEY_NAME); 445 | const conversionName = config.conversionName 446 | || context.getMetadata('conversion-name') 447 | || 'click'; 448 | 449 | // Create the experiment description container 450 | const descriptionContainer = document.createElement('div'); 451 | const detailsDiv = document.createElement('div'); 452 | detailsDiv.className = 'hlx-details'; 453 | detailsDiv.textContent = config.status; 454 | if (config.resolvedAudiences) { 455 | detailsDiv.appendChild(document.createTextNode(', ')); 456 | } 457 | if (config.resolvedAudiences && config.resolvedAudiences.length) { 458 | detailsDiv.appendChild(document.createTextNode(config.resolvedAudiences[0])); 459 | } else if (config.resolvedAudiences && !config.resolvedAudiences.length) { 460 | detailsDiv.appendChild(document.createTextNode('No audience resolved')); 461 | } 462 | if (config.variants[config.variantNames[0]].blocks.length) { 463 | detailsDiv.appendChild(document.createTextNode(', Blocks: ')); 464 | detailsDiv.appendChild(document.createTextNode(config.variants[config.variantNames[0]].blocks.join(','))); 465 | } 466 | 467 | const infoDiv = document.createElement('div'); 468 | infoDiv.className = 'hlx-info'; 469 | infoDiv.textContent = 'How is it going?'; 470 | descriptionContainer.appendChild(detailsDiv); 471 | descriptionContainer.appendChild(infoDiv); 472 | const pill = createPopupButton( 473 | `Experiment: ${config.id}`, 474 | { 475 | label: config.label, 476 | description: descriptionContainer, 477 | actions: [ 478 | ...config.manifest ? [{ label: 'Manifest', href: config.manifest }] : [], 479 | { 480 | label: '', 481 | onclick: async () => { 482 | // eslint-disable-next-line no-alert 483 | const key = window.prompt( 484 | 'Please enter your domain key:', 485 | window.localStorage.getItem(DOMAIN_KEY_NAME) || '', 486 | ); 487 | if (key && key.match(/[a-f0-9-]+/)) { 488 | window.localStorage.setItem(DOMAIN_KEY_NAME, key); 489 | const performanceMetrics = await fetchRumData(experiment, { 490 | ...options, 491 | conversionName, 492 | domainKey: key, 493 | }); 494 | if (performanceMetrics === null) { 495 | return; 496 | } 497 | populatePerformanceMetrics(pill, config, performanceMetrics, conversionName); 498 | } else if (key === '') { 499 | window.localStorage.removeItem(DOMAIN_KEY_NAME); 500 | } 501 | }, 502 | }, 503 | ], 504 | }, 505 | config.variantNames.map((vname) => createVariant(experiment, vname, config, options)), 506 | ); 507 | if (config.run) { 508 | pill.classList.add(`is-${context.toClassName(config.status)}`); 509 | } 510 | overlay.append(pill); 511 | 512 | const performanceMetrics = await fetchRumData(experiment, { 513 | ...options, domainKey, conversionName, 514 | }); 515 | if (performanceMetrics === null) { 516 | return; 517 | } 518 | populatePerformanceMetrics(pill, config, performanceMetrics, conversionName); 519 | } 520 | 521 | function createCampaign(campaign, isSelected, options) { 522 | const url = new URL(window.location.href); 523 | if (campaign !== 'default') { 524 | url.searchParams.set(options.campaignsQueryParameter, campaign); 525 | } else { 526 | url.searchParams.delete(options.campaignsQueryParameter); 527 | } 528 | 529 | return { 530 | label: `${campaign}`, 531 | actions: [{ label: 'Simulate', href: url.href }], 532 | isSelected, 533 | }; 534 | } 535 | 536 | /** 537 | * Create Badge if a Page is enlisted in a AEM Campaign 538 | * @return {Object} returns a badge or empty string 539 | */ 540 | async function decorateCampaignPill(overlay, options, context) { 541 | const campaigns = context.getAllMetadata(options.campaignsMetaTagPrefix); 542 | if (!Object.keys(campaigns).length) { 543 | return; 544 | } 545 | 546 | const usp = new URLSearchParams(window.location.search); 547 | const forcedAudience = usp.has(options.audiencesQueryParameter) 548 | ? context.toClassName(usp.get(options.audiencesQueryParameter)) 549 | : null; 550 | const audiences = campaigns.audience?.split(',').map(context.toClassName) || []; 551 | const resolvedAudiences = await context.getResolvedAudiences(audiences, options); 552 | const isActive = forcedAudience 553 | ? audiences.includes(forcedAudience) 554 | : (!resolvedAudiences || !!resolvedAudiences.length); 555 | const campaign = (usp.has(options.campaignsQueryParameter) 556 | ? context.toClassName(usp.get(options.campaignsQueryParameter)) 557 | : null) 558 | || (usp.has('utm_campaign') ? context.toClassName(usp.get('utm_campaign')) : null); 559 | 560 | const campaignDescriptionContainer = document.createElement('div'); 561 | const campaignDetailsDiv = document.createElement('div'); 562 | campaignDetailsDiv.className = 'hlx-details'; 563 | if (audiences.length && resolvedAudiences?.length) { 564 | campaignDetailsDiv.appendChild(document.createTextNode('Audience: ')); 565 | campaignDetailsDiv.appendChild(document.createTextNode(resolvedAudiences[0])); 566 | } else if (audiences.length && !resolvedAudiences?.length) { 567 | campaignDetailsDiv.textContent = 'No audience resolved'; 568 | } else if (!audiences.length || !resolvedAudiences) { 569 | campaignDetailsDiv.textContent = 'No audience configured'; 570 | } 571 | campaignDescriptionContainer.appendChild(campaignDetailsDiv); 572 | 573 | const pill = createPopupButton( 574 | `Campaign: ${campaign || 'default'}`, 575 | { 576 | label: 'Campaigns on this page:', 577 | description: campaignDescriptionContainer, 578 | }, 579 | [ 580 | createCampaign('default', !campaign || !isActive, options), 581 | ...Object.keys(campaigns) 582 | .filter((c) => c !== 'audience') 583 | .map((c) => createCampaign(c, isActive && context.toClassName(campaign) === c, options)), 584 | ], 585 | ); 586 | 587 | if (campaign && isActive) { 588 | pill.classList.add('is-active'); 589 | } 590 | overlay.append(pill); 591 | } 592 | 593 | function createAudience(audience, isSelected, options) { 594 | const url = new URL(window.location.href); 595 | url.searchParams.set(options.audiencesQueryParameter, audience); 596 | 597 | return { 598 | label: `${audience}`, 599 | actions: [{ label: 'Simulate', href: url.href }], 600 | isSelected, 601 | }; 602 | } 603 | 604 | /** 605 | * Create Badge if a Page is enlisted in a AEM Audiences 606 | * @return {Object} returns a badge or empty string 607 | */ 608 | async function decorateAudiencesPill(overlay, options, context) { 609 | const audiences = context.getAllMetadata(options.audiencesMetaTagPrefix); 610 | if (!Object.keys(audiences).length || !Object.keys(options.audiences).length) { 611 | return; 612 | } 613 | 614 | const resolvedAudiences = await context.getResolvedAudiences( 615 | Object.keys(audiences), 616 | options, 617 | context, 618 | ); 619 | const pill = createPopupButton( 620 | 'Audiences', 621 | { 622 | label: 'Audiences for this page:', 623 | }, 624 | [ 625 | createAudience('default', !resolvedAudiences.length || resolvedAudiences[0] === 'default', options), 626 | ...Object.keys(audiences) 627 | .filter((a) => a !== 'audience') 628 | .map((a) => createAudience(a, resolvedAudiences && resolvedAudiences[0] === a, options)), 629 | ], 630 | ); 631 | 632 | if (resolvedAudiences.length) { 633 | pill.classList.add('is-active'); 634 | } 635 | overlay.append(pill); 636 | } 637 | 638 | /** 639 | * Decorates Preview mode badges and overlays 640 | * @return {Object} returns a badge or empty string 641 | */ 642 | export default async function decoratePreviewMode(document, options, context) { 643 | try { 644 | const overlay = getOverlay(options); 645 | await decorateAudiencesPill(overlay, options, context); 646 | await decorateCampaignPill(overlay, options, context); 647 | await decorateExperimentPill(overlay, options, context); 648 | } catch (e) { 649 | // eslint-disable-next-line no-console 650 | console.log(e); 651 | } 652 | } 653 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | const MAX_SAMPLING_RATE = 10; // At a maximum we sample 1 in 10 requests 13 | 14 | export const DEFAULT_OPTIONS = { 15 | // Generic properties 16 | rumSamplingRate: MAX_SAMPLING_RATE, // 1 in 10 requests 17 | 18 | // Audiences related properties 19 | audiences: {}, 20 | audiencesMetaTagPrefix: 'audience', 21 | audiencesQueryParameter: 'audience', 22 | 23 | // Campaigns related properties 24 | campaignsMetaTagPrefix: 'campaign', 25 | campaignsQueryParameter: 'campaign', 26 | 27 | // Experimentation related properties 28 | experimentsRoot: '/experiments', 29 | experimentsConfigFile: 'manifest.json', 30 | experimentsMetaTag: 'experiment', 31 | experimentsQueryParameter: 'experiment', 32 | }; 33 | 34 | /** 35 | * Triggers the callback when the page is actually activated, 36 | * This is to properly handle speculative page prerendering and marketing events. 37 | * @param {Function} cb The callback to run 38 | */ 39 | async function onPageActivation(cb) { 40 | // Speculative prerender-aware execution. 41 | // See: https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API#unsafe_prerendering 42 | if (document.prerendering) { 43 | document.addEventListener('prerenderingchange', cb, { once: true }); 44 | } else { 45 | cb(); 46 | } 47 | } 48 | 49 | /** 50 | * Checks if the current engine is detected as being a bot. 51 | * @returns `true` if the current engine is detected as being, `false` otherwise 52 | */ 53 | function isBot() { 54 | return navigator.userAgent.match(/bot|crawl|spider/i); 55 | } 56 | 57 | /** 58 | * Checks if any of the configured audiences on the page can be resolved. 59 | * @param {string[]} applicableAudiences a list of configured audiences for the page 60 | * @param {object} options the plugin options 61 | * @returns Returns the names of the resolved audiences, or `null` if no audience is configured 62 | */ 63 | export async function getResolvedAudiences(applicableAudiences, options, context) { 64 | if (!applicableAudiences.length || !Object.keys(options.audiences).length) { 65 | return null; 66 | } 67 | // If we have a forced audience set in the query parameters (typically for simulation purposes) 68 | // we check if it is applicable 69 | const usp = new URLSearchParams(window.location.search); 70 | const forcedAudience = usp.has(options.audiencesQueryParameter) 71 | ? context.toClassName(usp.get(options.audiencesQueryParameter)) 72 | : null; 73 | if (forcedAudience) { 74 | return applicableAudiences.includes(forcedAudience) ? [forcedAudience] : []; 75 | } 76 | 77 | // Otherwise, return the list of audiences that are resolved on the page 78 | const results = await Promise.all( 79 | applicableAudiences 80 | .map((key) => { 81 | if (options.audiences[key] && typeof options.audiences[key] === 'function') { 82 | return options.audiences[key](); 83 | } 84 | return false; 85 | }), 86 | ); 87 | return applicableAudiences.filter((_, i) => results[i]); 88 | } 89 | 90 | /** 91 | * Replaces element with content from path 92 | * @param {string} path 93 | * @param {HTMLElement} main 94 | * @return Returns the path that was loaded or null if the loading failed 95 | */ 96 | async function replaceInner(path, main) { 97 | try { 98 | const resp = await fetch(path); 99 | if (!resp.ok) { 100 | // eslint-disable-next-line no-console 101 | console.log('error loading content:', resp); 102 | return null; 103 | } 104 | const html = await resp.text(); 105 | // parse with DOMParser to guarantee valid HTML, and no script execution(s) 106 | const dom = new DOMParser().parseFromString(html, 'text/html'); 107 | // do not use replaceWith API here since this would replace the main reference 108 | // in scripts.js as well and prevent proper decoration of the sections/blocks 109 | main.innerHTML = dom.querySelector('main').innerHTML; 110 | return path; 111 | } catch (e) { 112 | // eslint-disable-next-line no-console 113 | console.log(`error loading content: ${path}`, e); 114 | } 115 | return null; 116 | } 117 | 118 | /** 119 | * Parses the experimentation configuration sheet and creates an internal model. 120 | * 121 | * Output model is expected to have the following structure: 122 | * { 123 | * id: , 124 | * label: , 125 | * blocks: , 126 | * audiences: [], 127 | * status: Active | Inactive, 128 | * variantNames: [], 129 | * variants: { 130 | * [variantName]: { 131 | * label: 132 | * percentageSplit: , 133 | * pages: , 134 | * blocks: , 135 | * } 136 | * } 137 | * }; 138 | */ 139 | function parseExperimentConfig(json, context) { 140 | const config = {}; 141 | try { 142 | json.settings.data.forEach((line) => { 143 | const key = context.toCamelCase(line.Name); 144 | if (key === 'audience' || key === 'audiences') { 145 | config.audiences = line.Value ? line.Value.split(',').map((str) => str.trim()) : []; 146 | } else if (key === 'experimentName') { 147 | config.label = line.Value; 148 | } else { 149 | config[key] = line.Value; 150 | } 151 | }); 152 | const variants = {}; 153 | let variantNames = Object.keys(json.experiences.data[0]); 154 | variantNames.shift(); 155 | variantNames = variantNames.map((vn) => context.toCamelCase(vn)); 156 | variantNames.forEach((variantName) => { 157 | variants[variantName] = {}; 158 | }); 159 | let lastKey = 'default'; 160 | json.experiences.data.forEach((line) => { 161 | let key = context.toCamelCase(line.Name); 162 | if (!key) key = lastKey; 163 | lastKey = key; 164 | const vns = Object.keys(line); 165 | vns.shift(); 166 | vns.forEach((vn) => { 167 | const camelVN = context.toCamelCase(vn); 168 | if (key === 'pages' || key === 'blocks') { 169 | variants[camelVN][key] = variants[camelVN][key] || []; 170 | if (key === 'pages') variants[camelVN][key].push(new URL(line[vn]).pathname); 171 | else variants[camelVN][key].push(line[vn]); 172 | } else { 173 | variants[camelVN][key] = line[vn]; 174 | } 175 | }); 176 | }); 177 | config.variants = variants; 178 | config.variantNames = variantNames; 179 | return config; 180 | } catch (e) { 181 | // eslint-disable-next-line no-console 182 | console.log('error parsing experiment config:', e, json); 183 | } 184 | return null; 185 | } 186 | 187 | /** 188 | * Checks if the given config is a valid experimentation configuration. 189 | * @param {object} config the config to check 190 | * @returns `true` if it is valid, `false` otherwise 191 | */ 192 | export function isValidExperimentationConfig(config) { 193 | if (!config.variantNames 194 | || !config.variantNames.length 195 | || !config.variants 196 | || !Object.values(config.variants).length 197 | || !Object.values(config.variants).every((v) => ( 198 | typeof v === 'object' 199 | && !!v.blocks 200 | && !!v.pages 201 | && (v.percentageSplit === '' || !!v.percentageSplit) 202 | ))) { 203 | return false; 204 | } 205 | return true; 206 | } 207 | 208 | /** 209 | * Calculates percentage split for variants where the percentage split is not 210 | * explicitly configured. 211 | * Substracts from 100 the explicitly configured percentage splits, 212 | * and divides the remaining percentage, among the variants without explicit 213 | * percentage split configured 214 | * @param {Array} variant objects 215 | */ 216 | function inferEmptyPercentageSplits(variants) { 217 | const variantsWithoutPercentage = []; 218 | 219 | const remainingPercentage = variants.reduce((result, variant) => { 220 | if (!variant.percentageSplit) { 221 | variantsWithoutPercentage.push(variant); 222 | } 223 | const newResult = result - parseFloat(variant.percentageSplit || 0); 224 | return newResult; 225 | }, 1); 226 | if (variantsWithoutPercentage.length) { 227 | const missingPercentage = remainingPercentage / variantsWithoutPercentage.length; 228 | variantsWithoutPercentage.forEach((v) => { 229 | v.percentageSplit = missingPercentage.toFixed(4); 230 | }); 231 | } 232 | } 233 | 234 | /** 235 | * Gets experiment config from the metadata. 236 | * 237 | * @param {string} experimentId The experiment identifier 238 | * @param {string} instantExperiment The list of varaints 239 | * @returns {object} the experiment manifest 240 | */ 241 | function getConfigForInstantExperiment( 242 | experimentId, 243 | instantExperiment, 244 | pluginOptions, 245 | context, 246 | ) { 247 | const audience = context.getMetadata(`${pluginOptions.experimentsMetaTag}-audience`); 248 | const config = { 249 | label: `Instant Experiment: ${experimentId}`, 250 | audiences: audience ? audience.split(',').map(context.toClassName) : [], 251 | status: context.getMetadata(`${pluginOptions.experimentsMetaTag}-status`) || 'Active', 252 | startDate: context.getMetadata(`${pluginOptions.experimentsMetaTag}-start-date`), 253 | endDate: context.getMetadata(`${pluginOptions.experimentsMetaTag}-end-date`), 254 | id: experimentId, 255 | variants: {}, 256 | variantNames: [], 257 | }; 258 | 259 | const nbOfVariants = Number(instantExperiment); 260 | const pages = Number.isNaN(nbOfVariants) 261 | ? instantExperiment.split(',').map((p) => new URL(p.trim(), window.location).pathname) 262 | : new Array(nbOfVariants).fill(window.location.pathname); 263 | 264 | const splitString = context.getMetadata(`${pluginOptions.experimentsMetaTag}-split`); 265 | const splits = splitString 266 | // custom split 267 | ? splitString.split(',').map((i) => parseFloat(i) / 100) 268 | // even split fallback 269 | : [...new Array(pages.length)].map(() => 1 / (pages.length + 1)); 270 | 271 | config.variantNames.push('control'); 272 | config.variants.control = { 273 | percentageSplit: '', 274 | pages: [window.location.pathname], 275 | blocks: [], 276 | label: 'Control', 277 | }; 278 | 279 | pages.forEach((page, i) => { 280 | const vname = `challenger-${i + 1}`; 281 | config.variantNames.push(vname); 282 | config.variants[vname] = { 283 | percentageSplit: `${splits[i].toFixed(4)}`, 284 | pages: [page], 285 | blocks: [], 286 | label: `Challenger ${i + 1}`, 287 | }; 288 | }); 289 | inferEmptyPercentageSplits(Object.values(config.variants)); 290 | return (config); 291 | } 292 | 293 | /** 294 | * Gets experiment config from the manifest and transforms it to more easily 295 | * consumable structure. 296 | * 297 | * the manifest consists of two sheets "settings" and "experiences", by default 298 | * 299 | * "settings" is applicable to the entire test and contains information 300 | * like "Audience", "Status" or "Blocks". 301 | * 302 | * "experience" hosts the experiences in rows, consisting of: 303 | * a "Percentage Split", "Label" and a set of "Links". 304 | * 305 | * 306 | * @param {string} experimentId The experiment identifier 307 | * @param {object} pluginOptions The plugin options 308 | * @returns {object} containing the experiment manifest 309 | */ 310 | async function getConfigForFullExperiment(experimentId, pluginOptions, context) { 311 | let path; 312 | if (experimentId.includes(`/${pluginOptions.experimentsConfigFile}`)) { 313 | path = new URL(experimentId, window.location.origin).href; 314 | // eslint-disable-next-line no-param-reassign 315 | [experimentId] = path.split('/').splice(-2, 1); 316 | } else { 317 | path = `${pluginOptions.experimentsRoot}/${experimentId}/${pluginOptions.experimentsConfigFile}`; 318 | } 319 | try { 320 | const resp = await fetch(path); 321 | if (!resp.ok) { 322 | // eslint-disable-next-line no-console 323 | console.log('error loading experiment config:', resp); 324 | return null; 325 | } 326 | const json = await resp.json(); 327 | const config = pluginOptions.parser 328 | ? pluginOptions.parser(json, context) 329 | : parseExperimentConfig(json, context); 330 | if (!config) { 331 | return null; 332 | } 333 | config.id = experimentId; 334 | config.manifest = path; 335 | config.basePath = `${pluginOptions.experimentsRoot}/${experimentId}`; 336 | inferEmptyPercentageSplits(Object.values(config.variants)); 337 | config.status = context.getMetadata(`${pluginOptions.experimentsMetaTag}-status`) || config.status; 338 | return config; 339 | } catch (e) { 340 | // eslint-disable-next-line no-console 341 | console.log(`error loading experiment manifest: ${path}`, e); 342 | } 343 | return null; 344 | } 345 | 346 | function getDecisionPolicy(config) { 347 | const decisionPolicy = { 348 | id: 'content-experimentation-policy', 349 | rootDecisionNodeId: 'n1', 350 | decisionNodes: [{ 351 | id: 'n1', 352 | type: 'EXPERIMENTATION', 353 | experiment: { 354 | id: config.id, 355 | identityNamespace: 'ECID', 356 | randomizationUnit: 'DEVICE', 357 | treatments: Object.entries(config.variants).map(([key, props]) => ({ 358 | id: key, 359 | allocationPercentage: Number(props.percentageSplit) * 100, 360 | })), 361 | }, 362 | }], 363 | }; 364 | return decisionPolicy; 365 | } 366 | 367 | async function getConfig(experiment, instantExperiment, pluginOptions, context) { 368 | const usp = new URLSearchParams(window.location.search); 369 | const [forcedExperiment, forcedVariant] = usp.has(pluginOptions.experimentsQueryParameter) 370 | ? usp.get(pluginOptions.experimentsQueryParameter).split('/') 371 | : []; 372 | 373 | const experimentConfig = instantExperiment 374 | ? await getConfigForInstantExperiment(experiment, instantExperiment, pluginOptions, context) 375 | : await getConfigForFullExperiment(experiment, pluginOptions, context); 376 | 377 | // eslint-disable-next-line no-console 378 | console.debug(experimentConfig); 379 | if (!experimentConfig) { 380 | return null; 381 | } 382 | 383 | const forcedAudience = usp.has(pluginOptions.audiencesQueryParameter) 384 | ? context.toClassName(usp.get(pluginOptions.audiencesQueryParameter)) 385 | : null; 386 | 387 | experimentConfig.resolvedAudiences = await getResolvedAudiences( 388 | experimentConfig.audiences.map(context.toClassName), 389 | pluginOptions, 390 | context, 391 | ); 392 | experimentConfig.run = ( 393 | // experiment is active or forced 394 | (['active', 'on', 'true'].includes(context.toClassName(experimentConfig.status)) || forcedExperiment) 395 | // experiment has resolved audiences if configured 396 | && (!experimentConfig.resolvedAudiences || experimentConfig.resolvedAudiences.length) 397 | // forced audience resolves if defined 398 | && (!forcedAudience || experimentConfig.audiences.includes(forcedAudience)) 399 | && (!experimentConfig.startDate || new Date(experimentConfig.startDate) <= Date.now()) 400 | && (!experimentConfig.endDate || new Date(experimentConfig.endDate) > Date.now()) 401 | ); 402 | 403 | window.hlx = window.hlx || {}; 404 | window.hlx.experiment = experimentConfig; 405 | 406 | // eslint-disable-next-line no-console 407 | console.debug('run', experimentConfig.run, experimentConfig.audiences); 408 | if (forcedVariant && experimentConfig.variantNames.includes(forcedVariant)) { 409 | experimentConfig.selectedVariant = forcedVariant; 410 | } else { 411 | // eslint-disable-next-line import/extensions 412 | const { ued } = await import('./ued.js'); 413 | const decision = ued.evaluateDecisionPolicy(getDecisionPolicy(experimentConfig), {}); 414 | experimentConfig.selectedVariant = decision.items[0].id; 415 | } 416 | return experimentConfig; 417 | } 418 | 419 | export async function runExperiment(document, options, context) { 420 | if (isBot()) { 421 | return false; 422 | } 423 | 424 | const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; 425 | const experiment = context.getMetadata(pluginOptions.experimentsMetaTag); 426 | if (!experiment) { 427 | return false; 428 | } 429 | const variants = context.getMetadata('instant-experiment') 430 | || context.getMetadata(`${pluginOptions.experimentsMetaTag}-variants`); 431 | let experimentConfig; 432 | try { 433 | experimentConfig = await getConfig(experiment, variants, pluginOptions, context); 434 | } catch (err) { 435 | // eslint-disable-next-line no-console 436 | console.error('Invalid experiment config.', err); 437 | } 438 | if (!experimentConfig || !isValidExperimentationConfig(experimentConfig)) { 439 | // eslint-disable-next-line no-console 440 | console.warn('Invalid experiment config. Please review your metadata, sheet and parser.'); 441 | return false; 442 | } 443 | 444 | const usp = new URLSearchParams(window.location.search); 445 | const forcedVariant = usp.has(pluginOptions.experimentsQueryParameter) 446 | ? usp.get(pluginOptions.experimentsQueryParameter).split('/')[1] 447 | : null; 448 | if (!experimentConfig.run && !forcedVariant) { 449 | // eslint-disable-next-line no-console 450 | console.warn('Experiment will not run. It is either not active or its configured audiences are not resolved.'); 451 | return false; 452 | } 453 | // eslint-disable-next-line no-console 454 | console.debug(`running experiment (${window.hlx.experiment.id}) -> ${window.hlx.experiment.selectedVariant}`); 455 | 456 | if (experimentConfig.selectedVariant === experimentConfig.variantNames[0]) { 457 | document.body.classList.add(`experiment-${context.toClassName(experimentConfig.id)}`); 458 | document.body.classList.add(`variant-${context.toClassName(experimentConfig.selectedVariant)}`); 459 | onPageActivation(() => { 460 | context.sampleRUM('experiment', { 461 | source: experimentConfig.id, 462 | target: experimentConfig.selectedVariant, 463 | }); 464 | }); 465 | return false; 466 | } 467 | 468 | const { pages } = experimentConfig.variants[experimentConfig.selectedVariant]; 469 | if (!pages.length) { 470 | return false; 471 | } 472 | 473 | const currentPath = window.location.pathname; 474 | const control = experimentConfig.variants[experimentConfig.variantNames[0]]; 475 | const index = control.pages.indexOf(currentPath); 476 | if (index < 0) { 477 | return false; 478 | } 479 | 480 | // Fullpage content experiment 481 | document.body.classList.add(`experiment-${context.toClassName(experimentConfig.id)}`); 482 | let result; 483 | if (pages[index] !== currentPath) { 484 | result = await replaceInner(pages[index], document.querySelector('main')); 485 | } else { 486 | result = currentPath; 487 | } 488 | experimentConfig.servedExperience = result || currentPath; 489 | if (!result) { 490 | // eslint-disable-next-line no-console 491 | console.debug(`failed to serve variant ${window.hlx.experiment.selectedVariant}. Falling back to ${experimentConfig.variantNames[0]}.`); 492 | } 493 | document.body.classList.add(`variant-${context.toClassName(result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0])}`); 494 | onPageActivation(() => { 495 | context.sampleRUM('experiment', { 496 | source: experimentConfig.id, 497 | target: result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0], 498 | }); 499 | }); 500 | return result; 501 | } 502 | 503 | export async function runCampaign(document, options, context) { 504 | if (isBot()) { 505 | return false; 506 | } 507 | 508 | const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; 509 | const usp = new URLSearchParams(window.location.search); 510 | const campaign = (usp.has(pluginOptions.campaignsQueryParameter) 511 | ? context.toClassName(usp.get(pluginOptions.campaignsQueryParameter)) 512 | : null) 513 | || (usp.has('utm_campaign') ? context.toClassName(usp.get('utm_campaign')) : null); 514 | if (!campaign) { 515 | return false; 516 | } 517 | 518 | let audiences = context.getMetadata(`${pluginOptions.campaignsMetaTagPrefix}-audience`); 519 | let resolvedAudiences = null; 520 | if (audiences) { 521 | audiences = audiences.split(',').map(context.toClassName); 522 | resolvedAudiences = await getResolvedAudiences(audiences, pluginOptions, context); 523 | if (!!resolvedAudiences && !resolvedAudiences.length) { 524 | return false; 525 | } 526 | } 527 | 528 | const allowedCampaigns = context.getAllMetadata(pluginOptions.campaignsMetaTagPrefix); 529 | if (!Object.keys(allowedCampaigns).includes(campaign)) { 530 | return false; 531 | } 532 | 533 | const urlString = allowedCampaigns[campaign]; 534 | if (!urlString) { 535 | return false; 536 | } 537 | 538 | window.hlx.campaign = { selectedCampaign: campaign }; 539 | if (resolvedAudiences) { 540 | window.hlx.campaign.resolvedAudiences = window.hlx.campaign; 541 | } 542 | 543 | try { 544 | const url = new URL(urlString); 545 | const result = await replaceInner(url.pathname, document.querySelector('main')); 546 | window.hlx.campaign.servedExperience = result || window.location.pathname; 547 | if (!result) { 548 | // eslint-disable-next-line no-console 549 | console.debug(`failed to serve campaign ${campaign}. Falling back to default content.`); 550 | } 551 | document.body.classList.add(`campaign-${campaign}`); 552 | onPageActivation(() => { 553 | context.sampleRUM('campaign', { 554 | source: window.location.href, 555 | target: result ? campaign : 'default', 556 | }); 557 | }); 558 | return result; 559 | } catch (err) { 560 | // eslint-disable-next-line no-console 561 | console.error(err); 562 | return false; 563 | } 564 | } 565 | 566 | export async function serveAudience(document, options, context) { 567 | if (isBot()) { 568 | return false; 569 | } 570 | 571 | const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; 572 | const configuredAudiences = context.getAllMetadata(pluginOptions.audiencesMetaTagPrefix); 573 | if (!Object.keys(configuredAudiences).length) { 574 | return false; 575 | } 576 | 577 | const audiences = await getResolvedAudiences( 578 | Object.keys(configuredAudiences).map(context.toClassName), 579 | pluginOptions, 580 | context, 581 | ); 582 | if (!audiences || !audiences.length) { 583 | return false; 584 | } 585 | 586 | const usp = new URLSearchParams(window.location.search); 587 | const forcedAudience = usp.has(pluginOptions.audiencesQueryParameter) 588 | ? context.toClassName(usp.get(pluginOptions.audiencesQueryParameter)) 589 | : null; 590 | 591 | const selectedAudience = forcedAudience || audiences[0]; 592 | const urlString = configuredAudiences[selectedAudience]; 593 | if (!urlString) { 594 | return false; 595 | } 596 | 597 | window.hlx.audience = { selectedAudience }; 598 | 599 | try { 600 | const url = new URL(urlString); 601 | const result = await replaceInner(url.pathname, document.querySelector('main')); 602 | window.hlx.audience.servedExperience = result || window.location.pathname; 603 | if (!result) { 604 | // eslint-disable-next-line no-console 605 | console.debug(`failed to serve audience ${selectedAudience}. Falling back to default content.`); 606 | } 607 | document.body.classList.add(audiences.map((audience) => `audience-${audience}`)); 608 | onPageActivation(() => { 609 | context.sampleRUM('audiences', { 610 | source: window.location.href, 611 | target: result ? forcedAudience || audiences.join(',') : 'default', 612 | }); 613 | }); 614 | return result; 615 | } catch (err) { 616 | // eslint-disable-next-line no-console 617 | console.error(err); 618 | return false; 619 | } 620 | } 621 | 622 | window.hlx.patchBlockConfig?.push((config) => { 623 | const { experiment } = window.hlx; 624 | 625 | // No experiment is running 626 | if (!experiment || !experiment.run) { 627 | return config; 628 | } 629 | 630 | // The current experiment does not modify the block 631 | if (experiment.selectedVariant === experiment.variantNames[0] 632 | || !experiment.variants[experiment.variantNames[0]].blocks 633 | || !experiment.variants[experiment.variantNames[0]].blocks.includes(config.blockName)) { 634 | return config; 635 | } 636 | 637 | // The current experiment does not modify the block code 638 | const variant = experiment.variants[experiment.selectedVariant]; 639 | if (!variant.blocks.length) { 640 | return config; 641 | } 642 | 643 | let index = experiment.variants[experiment.variantNames[0]].blocks.indexOf(''); 644 | if (index < 0) { 645 | index = experiment.variants[experiment.variantNames[0]].blocks.indexOf(config.blockName); 646 | } 647 | if (index < 0) { 648 | index = experiment.variants[experiment.variantNames[0]].blocks.indexOf(`/blocks/${config.blockName}`); 649 | } 650 | if (index < 0) { 651 | return config; 652 | } 653 | 654 | let origin = ''; 655 | let path; 656 | if (/^https?:\/\//.test(variant.blocks[index])) { 657 | const url = new URL(variant.blocks[index]); 658 | // Experimenting from a different branch 659 | if (url.origin !== window.location.origin) { 660 | origin = url.origin; 661 | } 662 | // Experimenting from a block path 663 | if (url.pathname !== '/') { 664 | path = url.pathname; 665 | } else { 666 | path = `/blocks/${config.blockName}`; 667 | } 668 | } else { // Experimenting from a different branch on the same branch 669 | path = `/blocks/${variant.blocks[index]}`; 670 | } 671 | if (!origin && !path) { 672 | return config; 673 | } 674 | 675 | const { codeBasePath } = window.hlx; 676 | return { 677 | ...config, 678 | cssPath: `${origin}${codeBasePath}${path}/${config.blockName}.css`, 679 | jsPath: `${origin}${codeBasePath}${path}/${config.blockName}.js`, 680 | }; 681 | }); 682 | 683 | let isAdjusted = false; 684 | function adjustedRumSamplingRate(checkpoint, options, context) { 685 | const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; 686 | return (data) => { 687 | if (!window.hlx.rum.isSelected && !isAdjusted) { 688 | isAdjusted = true; 689 | // adjust sampling rate based on project config … 690 | window.hlx.rum.weight = Math.min( 691 | window.hlx.rum.weight, 692 | // … but limit it to the 10% sampling at max to avoid losing anonymization 693 | // and reduce burden on the backend 694 | Math.max(pluginOptions.rumSamplingRate, MAX_SAMPLING_RATE), 695 | ); 696 | window.hlx.rum.isSelected = (window.hlx.rum.random * window.hlx.rum.weight < 1); 697 | if (window.hlx.rum.isSelected) { 698 | context.sampleRUM(checkpoint, data); 699 | } 700 | } 701 | return true; 702 | }; 703 | } 704 | 705 | function adjustRumSampligRate(document, options, context) { 706 | const checkpoints = ['audiences', 'campaign', 'experiment']; 707 | if (context.sampleRUM.always) { // RUM v1.x 708 | checkpoints.forEach((ck) => { 709 | context.sampleRUM.always.on(ck, adjustedRumSamplingRate(ck, options, context)); 710 | }); 711 | } else { // RUM 2.x 712 | document.addEventListener('rum', (event) => { 713 | if (event.detail 714 | && event.detail.checkpoint 715 | && checkpoints.includes(event.detail.checkpoint)) { 716 | adjustedRumSamplingRate(event.detail.checkpoint, options, context); 717 | } 718 | }); 719 | } 720 | } 721 | 722 | export async function loadEager(document, options, context) { 723 | onPageActivation(() => { 724 | adjustRumSampligRate(document, options, context); 725 | }); 726 | let res = await runCampaign(document, options, context); 727 | if (!res) { 728 | res = await runExperiment(document, options, context); 729 | } 730 | if (!res) { 731 | res = await serveAudience(document, options, context); 732 | } 733 | } 734 | 735 | export async function loadLazy(document, options, context) { 736 | const pluginOptions = { 737 | ...DEFAULT_OPTIONS, 738 | ...(options || {}), 739 | }; 740 | // do not show the experimentation pill on prod domains 741 | if (window.location.hostname.endsWith('.live') 742 | || (typeof options.isProd === 'function' && options.isProd()) 743 | || (options.prodHost 744 | && (options.prodHost === window.location.host 745 | || options.prodHost === window.location.hostname 746 | || options.prodHost === window.location.origin))) { 747 | return; 748 | } 749 | // eslint-disable-next-line import/no-cycle 750 | const preview = await import('./preview.js'); 751 | preview.default(document, pluginOptions, { ...context, getResolvedAudiences }); 752 | } 753 | --------------------------------------------------------------------------------