Sitemap
Saravana Prakash

Saravana Prakash

AEM Fullstack Enthusiast. Working on AEMCaaS, Adobe EDS, Adobe IO and other Adobe Marketing Cloud tools

Wallet-Sized AEM

8 min readOct 8, 2026

--

How to run Content Advisor (previously Assets Selector) as chrome plugin

Press enter or click to view image in full size
Photo by Emil Kalibradov on Unsplash

Problem Statement

My usecase starts with AEM DAM used for Content Delivery. My website is integrated to a 3rd party Ads Management System. The Ads Manager allows to curate the textual content for ads within the platform. But the images/videos, the digital assets had to be served from AEM DAM. For the obvious reason that DAM/Dynamic Media powered delivery supports renditioning, optimization, viewer. So AdOps team used to upload images/videos into DAM, allow asset to process completely, and copy the asset url into Ads portal Kevel.

To summarize, the workflow looked like this

Press enter or click to view image in full size

AdOps engineer had to multiple times switch between Kevel and AEM Console before an Ad could go live. The purpose of AEM, in AdOps procedure, is to serve assets. My problem statement was, how to avoid switching between aem and kevel multiple times?

Solution: Use Content Advisor

With simple usecase, load DAM, select asset, copy url and update ad, the Content Advisor was a perfect tool for us. I started working on integrating Content Advisor into the Ads Portal. The next hurdle was, the Ads Portal was SaaS product, that didnt support extendability. It didnt allow to customize thier portal. I also tried its vice versa, to load the Ads platform into AEM DAM, but that further complicates, as for single field, had to bandage entire portal functionality.

Next solution, was to build a Chrome extension that ll run the ContentAdvisor, so AdOps will work from Ads Portal, and when asset url needed, would open the chrome extension, launch dam, and select asset. This works, except few more hurdles

Implementing Chrome Extension

I started with a simple Helloworld extension as explained here. With a html, its js, and a manifest, the basic extension loaded. Next loaded the UMD version of advisor script directly from html as explained here.

Get Saravana Prakash’s stories in your inbox

Join Medium for free to get updates from this writer.

Hurdle 1- Cors error: when running the extension, from the ad portal, browser was throwing cors errors to hit https://experience.adobe.com/solutions/CQ-assets-selectors/static-assets/resources/assets-selectors.js

To workaround, I had to download and keep assets-selector.js local inside the extension. So my default_popup html for extension looked like this

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AEM SideKick</title>
<style>
body {
font-family: 'Segoe UI', Arial, sans-serif;
padding: 1rem;
margin: 0;
}
#container {
width: 800px;
}
.assetSelectorContainer {
margin-top: 1rem;
width: 770px;
}
h3 {
margin-top: 0;
color: #1a237e;
letter-spacing: 0.5px;
font-size: 1.7rem;
text-align: center;
}
#asset-selector {
height: 540px;
width: 100%;
min-width: unset;
min-height: unset;
max-width: 100%;
max-height: 100%;
box-sizing: border-box;
transition: width 0.3s, height 0.3s;
}
#status {
margin-top: 14px;
font-size: 13px;
color: #374151;
min-height: 18px;
text-align: center;
}
</style>
</head>
<body>
<h3>AEM SideKick</h3>

<div id="container">
<label for="fileInput">Ad Preview URL:</label>
<a href="#" target="_blank" id="previewUrl"></a>
<div class="assetSelectorContainer">
<div id="asset-selector" style="height: 540px; border: 1px solid #ccc; margin-bottom: 24px;"></div>
</div>
</div>
<div id="status"></div>
<div class="page-data" style="font-size: 0.25rem;"> </div>
<script src="assets-selector.js"></script>
<script src="popup.js"></script>
</body>
</html>

Hurdle 2 — Too many relogins: The next hurdle was, authentication. For ContentAdvisor to run, it needs Ims authentication. This also works well, wherein extension would always pop open ims login screen and user had to keyin credentials. This worked, but wasn’t desirable. Everytime user opened the extension, a new session started and user had to login. This was worse to earlier experience, when user kept 2 tabs for AdsPortal; DamPortal and switched back-n-forth. That experience was better compared to forcing relogin everytime.

Solution was to store auth credentials. While chrome supported few ways to safely store credentials and reuse, the realestate on extension was too small. Scrolling through the small window to login was daunting. Finally, decided to cutdown login and use a technical service account for reading assets.

I wrote a simple runtime action that will authenticate to AEM using a technical account and return the access token. This token was cached into sessionStorage and used by ContentAdvisor. Hence my popup.js bloated quickly like this

const hideElements = () => {
document.getElementById("container").style.display = "none";
};
const showElements = () => {
document.getElementById("container").style.display = "block";
};
window.onload = function () {
const statusElement = document.getElementById("status");
if (!chrome.tabs) {
hideElements();
statusElement.textContent =
"Please refresh page and reopen extension. chrome.tabs API not available";
return;
}
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (!tabs || !tabs[0]) {
hideElements();
statusElement.textContent =
"Please refresh page and reopen extension. No active tab found";
return;
}
chrome.tabs.sendMessage(
tabs[0].id,
{ action: "getKevelModalValues" },
function (response) {
if (chrome.runtime.lastError) {
hideElements();
statusElement.textContent =
"Please refresh page and reopen extension. Error: " +
chrome.runtime.lastError.message;
} else if (response && response.current !== undefined) {
showElements();
let adId = "";
let zone = '';
let primaryImageUrl = "";
let adHeight = "";
let adWidth = "";
if (response.current) {
adId = response.current.match(/:mapid\s+([0-9]+)/)?.[1] || "";
zone = response.current.match(/:zone\s+"([^"]*)"/)?.[1] || '';
adWidth =
response.current.match(/:adtypewidth\s+([0-9]+)/)?.[1] || "";
adHeight =
response.current.match(/:adtypeheight\s+([0-9]+)/)?.[1] || "";
primaryImageUrl =
response.current.match(
/:ctPrimaryImageDesktop\s+"([^"]*)"/,
)?.[1] || "";
statusElement.textContent =
"Kevel Ad Modal data extracted. Ad ID: " +
adId +
", Primary Image URL: " +
primaryImageUrl;
} else {
hideElements();
statusElement &&
(statusElement.textContent = "Kevel Ad Modal must be opened.");
return;
}
if (!adId && !zone) {
hideElements();
statusElement &&
(statusElement.textContent =
"Ad Id missing. Please create ad first and open modal and then reopen extension.");
return;
}

const previewUrl = document.getElementById("previewUrl");
previewUrl.href = `https://my.example.com/?previewAdId=${adId}`;
previewUrl.textContent = `https://my.example.com/?previewAdId=${adId}`;

// Pass loginName from response to asset selector
const loginName = response.loginName || 'unknown';
loadAssetSelectorPlugin(adHeight, adWidth, loginName);
} else {
hideElements();
statusElement.textContent =
"Kevel Ad Modal must be opened before using this extension.";
}
},
);
});
};
let selectedFile = null;

function setPopupSize(isLarge) {
const body = document.body;
const assetSelector = document.getElementById("asset-selector");
if (isLarge) {
body.style.width = "800px";
if (assetSelector) {
assetSelector.style.height = "700px";
assetSelector.style.minWidth = "600px";
assetSelector.style.minHeight = "600px";
}
} else {
body.style.width = "500px";
if (assetSelector) {
assetSelector.style.height = "540px";
assetSelector.style.minWidth = "";
assetSelector.style.minHeight = "";
}
}
}

function loadAssetSelectorPlugin(adHeight, adWidth, loginName) {
const container = document.getElementById("asset-selector");
const statusDiv = document.getElementById("status");

if (!container || !statusDiv) return;
statusDiv.textContent = "Loading AEM Asset Selector...";

// Show loading spinner/message in asset-selector container
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
<div>
<div class="spinner" style="margin:auto;width:40px;height:40px;border:4px solid #ccc;border-top:4px solid #1976d2;border-radius:50%;animation:spin 1s linear infinite;"></div>
<div style="margin-top:12px;text-align:center;color:#1976d2;font-weight:500;">Loading Asset Selector...</div>
</div>
</div>
<style>
@keyframes spin { 100% { transform: rotate(360deg); } }
</style>
`;

// The assets-selector.js script is loaded statically via popup.html
if (typeof window.PureJSSelectors === "undefined") {
statusDiv.textContent = "❌ Asset Selector library not loaded";
container.innerHTML = "";
return;
}

statusDiv.textContent = "Connecting to your AEM instance...";

// Log the loginName for debugging
const props = {
repositoryId: "",
rail: false,
imsToken: "", // will be set after fetch or cache
selectionType: "single",
rootPath:
"/content/dam/apps/keveladvertising/advertising",
hideTreeNav: false,
dialogSize: "fullscreen",
featureSet:["upload"],
uploadConfig: {
metadataSchema: [
{
mapToProperty: 'kevelAd:creatorName',
value: loginName,
element: 'hidden',
},
],
}
};

// Try to get token from sessionStorage
let cachedToken = null;
let cachedTokenExpiry = null;
try {
cachedToken = sessionStorage.getItem("aemToken");
cachedTokenExpiry = sessionStorage.getItem("aemTokenExpiry");
} catch (e) {}

const now = Date.now();
if (
cachedToken &&
cachedTokenExpiry &&
now < parseInt(cachedTokenExpiry, 10)
) {
props.imsToken = cachedToken;
setupAssetSelector();
} else {
fetch(
"https://**.adobeioruntime.net/api/v1/web/asset-selector-token/getToken",
)
.then((res) => res.json())
.then((tokenData) => {
props.imsToken = tokenData.imsToken || "";
// Store in sessionStorage for 25 minutes (token lifetime is usually 30min)
try {
sessionStorage.setItem("aemToken", props.imsToken);
sessionStorage.setItem(
"aemTokenExpiry",
(now + 25 * 60 * 1000).toString(),
);
} catch (e) {}
setupAssetSelector();
})
.catch((err) => {
statusDiv.textContent = "❌ Failed to fetch IMS token: " + err;
container.innerHTML = "";
});
}

function setupAssetSelector() {
setPopupSize(true); // Enlarge popup for asset selector
// Add handleSelection to get asset name when Select is clicked
props.handleSelection = function (selectedAssets) {
if (Array.isArray(selectedAssets) && selectedAssets.length > 0) {
const assetName =
selectedAssets[0].name || selectedAssets[0].title || "(no name)";
// Print all required repo metadata fields
const asset = selectedAssets[0];
const dmUrl =
asset["repo:scene7Domain"] +
"is/image/" +
asset["repo:scene7File"] +
"?wid=" +
adWidth +
"&hei=" +
adHeight +
"&qlt=100";
// Send message to content script to update :ctPrimaryImageDesktop
chrome.tabs.query(
{ active: true, currentWindow: true },
function (tabs) {
if (tabs && tabs[0]) {
chrome.tabs.sendMessage(tabs[0].id, {
action: "updateCtPrimaryImageDesktop",
value: dmUrl,
});
}
},
);
// Hide the asset-selector plugin after selection
container.style.display = "none";
setPopupSize(false); // Restore popup size
// Show a success message and info for the user
statusDiv.innerHTML = `<div style="color: #388e3c; font-weight: 600; margin-top: 18px; font-size: 1.1rem;">✅ Asset selected!<br>Image URL has been auto-populated into the Ad Modal Image field.<br><span style='font-size:0.95rem;color:#333;'>You can now review or save your changes in the Ad Modal.</span></div>`;
} else {
statusDiv.textContent = "No asset selected.";
}
};
// Clear loading spinner before rendering selector
container.innerHTML = "";
window.PureJSSelectors.renderAssetSelector(container, props, function () {
statusDiv.innerHTML = `<div style="color: #1976d2; font-weight: 500; margin-top: 18px; font-size: 1.05rem;">✅ Asset Selector ready!<br><span style='font-size:0.95rem;color:#333;'>Browse and select an asset. The image URL will be auto-filled in the Ad Modal after selection.</span></div>`;
});
}
}

Hurdle 3 — Updating asset url into AdsPortal: Asset Selector and the Ads platform were coupled. I had to read values from the portal to search assets in AEM and viceversa, copy dam urls into portal. First I created a custom event getKevelModalValues that will be called once chrome extension tab is loaded. To update the AdsPortal with asset url, created another custom event updateCtPrimaryImageDesktop . The event will be fired with onSelection function, on hit of select button from Assets Selector. And hijacked these custom events into separate content.js like this

// content.js
// This script runs in the context of the web page and listens for messages from the popup.


(function() {

let form = '';
let current = '';
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'getKevelModalValues') {
function tryFindForm() {
form = document.querySelector('.modal.fade.in .modal-body .modal-scroll-container form');
let loginName = null;
const strongElem = document.querySelector('strong[adzerk-logical-id="nav-login-name"]');
if (strongElem) {
loginName = strongElem.textContent.trim();
}
if (form) {
current = form.getAttribute('current');
sendResponse({ current, loginName });
} else {
sendResponse({ current: null, loginName, error: 'Form element not found after waiting' });
}
}
tryFindForm();
return true; // keep message channel open for async response
}
if (request.action === 'updateCtPrimaryImageDesktop') {
// Try to find the input for ctPrimaryImageDesktop and set its value
// Try by adzerk-logical-id first, then fallback to id
if(form && current) {
const textArea = form?.querySelector('input[adzerk-logical-id="custom-fields-input-ctPrimaryImageDesktop"]');
const inputId = textArea?.id;
const input = document.getElementById(inputId);
if (input) {
input.value = request.value;
// Optionally, trigger input/change events if needed by the app
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
}
});
})();

The final chrome extension looked like this. A chrome extension that runs Asset Selector.

Press enter or click to view image in full size

The final complete extension is uploaded in git:

https://github.com/bsaravanaprakash/contentadvisor-chrome-extension/tree/main

Happy Coding!

--

--

Saravana Prakash
Saravana Prakash

AEM Fullstack Enthusiast. Working on AEMCaaS, Adobe EDS, Adobe IO and other Adobe Marketing Cloud tools