Viewing File: /usr/local/cpanel/whostmgr/docroot/cgi/softaculous/lib/panels/cp/meridian/softaculous-nav.js

/*
 * Adds "Softaculous" and "WordPress" items to the cPanel Meridian theme's
 * sidebar. Which of the two show, and their labels, are read from data-*
 * attributes on this file's own <script> tag (see softpanel.php's
 * save_topscripts_index(), which writes them and symlinks this file's
 * directory into place under the live Meridian theme directory):
 *
 *   data-show-softaculous="1|0"   data-softaculous-label="..."
 *   data-show-wordpress="1|0"     data-wordpress-label="..."
 *
 * Meridian's shell is a React SPA rebuilt by cPanel on every theme update,
 * so its internal nav data (label/order/icon arrays) is not reachable from
 * here - those are private closure variables, not exposed on window, and
 * their exact minified names change from build to build. Instead of
 * depending on any of that, this clones an existing sidebar item (a
 * structure cPanel controls and keeps stable across rebuilds far more than
 * internal variable names) to get pixel-correct markup "for free", then
 * points the clone(s) at Softaculous/WordPress.
 *
 * The theme renders more than one parallel copy of the nav list at once
 * (e.g. a desktop and a mobile/collapsed variant), so every matching item is
 * cloned, not just the first. The SPA also re-renders the sidebar on
 * client-side navigation without a full page load - including recreating
 * the very anchor elements we cloned from - and toggles sidebar
 * collapse/expand by mutating each item's class attribute in place. A
 * MutationObserver watches both, and re-injection is idempotent by
 * checking "does this list already have one of our items", not by marking
 * the (possibly since-replaced) source anchor.
 */
(function(){

	// Captured synchronously at parse time - document.currentScript is only
	// valid while this (classic, non-deferred) script is the one executing,
	// so it must be read here and not inside any function called later
	// (an observer callback, a timeout, ...).
	var scriptEl = document.currentScript;

	var CLASS_NAME = 'softaculous-nav-item';
	var AFTER_LABEL = 'websites'; // Substring match against e.g. "Websites & Apps" - anchor items are inserted after this one.

	function readConfig(){

		var ds = (scriptEl && scriptEl.dataset) || {};

		return [
			{
				key: 'softaculous',
				show: ds.showSoftaculous !== '0', // Default shown.
				label: ds.softaculousLabel || 'Softaculous',
				href: 'softaculous/index.live.php',
				icon: 'ri-apps-line'
			},
			{
				key: 'wordpress',
				show: ds.showWordpress === '1', // Default hidden.
				label: 'WordPress', // ds.wordpressLabel ||
				href: 'softaculous/index.live.php?act=wordpress',
				icon: 'ri-wordpress-fill'
			}
		];
	}

	var ITEMS = readConfig();

	function findAnchors(){

		var items = document.querySelectorAll('a.nav-item');
		if(!items.length) return [];

		// One match per parallel nav-item list, not just the first one overall.
		var matches = [];
		for(var i = 0; i < items.length; i++){
			var label = (items[i].textContent || '').trim().toLowerCase();
			if(label.indexOf(AFTER_LABEL) !== -1) matches.push(items[i]);
		}

		if(matches.length) return matches;

		// Fallback: no "Websites"-ish item anywhere - append after the last
		// nav-item within each distinct parent list instead.
		var parents = [];
		for(var i = 0; i < items.length; i++){
			if(parents.indexOf(items[i].parentNode) === -1) parents.push(items[i].parentNode);
		}

		for(var p = 0; p < parents.length; p++){
			var lastInParent = null;
			for(var c = 0; c < parents[p].children.length; c++){
				if(parents[p].children[c].matches && parents[p].children[c].matches('a.nav-item')){
					lastInParent = parents[p].children[c];
				}
			}
			if(lastInParent) matches.push(lastInParent);
		}

		return matches;
	}

	function findDirectChild(parent, cls){

		var kids = parent.children;

		for(var i = 0; i < kids.length; i++){
			if(kids[i].classList.contains(cls)) return kids[i];
		}

		return null;
	}

	// Meridian's own collapsed-sidebar tooltip is a Radix UI Tooltip: a
	// portal-rendered popover shown by React's OWN hover/focus handling,
	// tied to that specific element's Fiber instance. cloneNode() only
	// copies markup and static attributes, never JS event listeners or
	// React's internal instance mapping, so there is no way to make Radix's
	// own tooltip fire for our clone - a plain `title` attribute is the
	// fallback, but looks and behaves nothing like it (no positioning, no
	// delay, native browser styling).
	//
	// Instead: a small tooltip of our own, shown on hover/focus, reusing the
	// EXACT class list Radix's tooltip content renders with (captured from
	// the live page) so it is pixel-identical for free - those Tailwind
	// utility classes are already compiled into Meridian's CSS bundle, we
	// are not adding any of our own. Deliberately dropped: the
	// animate-in/data-[state=...] classes, which are driven by Radix's own
	// open/closed state machine that we are not reproducing - showing/hiding
	// is instant instead of animated.
	var TOOLTIP_CLASS = 'z-popover max-w-xs overflow-hidden rounded-md bg-surface px-3 py-1.5 text-sm text-text-primary border border-border shadow-popover';
	var TOOLTIP_GAP = 8; // px between trigger and tooltip, matching Radix's default sideOffset.

	var tooltipEl = null;

	function ensureTooltipEl(){

		if(tooltipEl) return tooltipEl;

		tooltipEl = document.createElement('div');
		tooltipEl.className = TOOLTIP_CLASS;
		tooltipEl.style.position = 'fixed';
		tooltipEl.style.zIndex = '500';
		tooltipEl.style.pointerEvents = 'none';
		tooltipEl.hidden = true;

		// Outside document.body's subtree on purpose: our MutationObserver
		// below watches document.body for childList/class changes, and a
		// node whose own visibility we toggle on hover would otherwise be a
		// mutation the observer sees and reacts to on every show/hide.
		(document.documentElement || document.body).appendChild(tooltipEl);

		return tooltipEl;
	}

	function showTooltip(trigger, label){

		// Only meaningful when collapsed - expanded mode already shows the
		// label as visible text, same as Meridian's own items.
		if(!trigger.classList.contains('nav-item-collapsed')) return;

		var el = ensureTooltipEl();
		el.textContent = label;
		el.hidden = false;

		var rect = trigger.getBoundingClientRect();

		el.style.left = Math.round(rect.right + TOOLTIP_GAP) + 'px';
		el.style.top = Math.round(rect.top + rect.height / 2) + 'px';
		el.style.transform = 'translateY(-50%)';
	}

	function hideTooltip(){
		if(tooltipEl) tooltipEl.hidden = true;
	}

	function attachTooltip(node, label){

		node.addEventListener('mouseenter', function(){ showTooltip(node, label); });
		node.addEventListener('mouseleave', hideTooltip);
		node.addEventListener('focus', function(){ showTooltip(node, label); });
		node.addEventListener('blur', hideTooltip);
	}

	function setLabel(node, label){

		var walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null);
		var text;

		while((text = walker.nextNode())){
			if(text.nodeValue && text.nodeValue.trim()){
				text.nodeValue = label;
				return;
			}
		}
	}

	function setIcon(node, iconClass){

		var icons = node.querySelectorAll('[class*="ri-"]');

		for(var i = 0; i < icons.length; i++){
			if(/\bri-[a-z0-9-]+-line\b/.test(icons[i].className) || /\bri-[a-z0-9-]+-fill\b/.test(icons[i].className)){
				icons[i].className = icons[i].className
					.replace(/\bri-[a-z0-9-]+-line\b/, iconClass)
					.replace(/\bri-[a-z0-9-]+-fill\b/, iconClass);
				return;
			}
		}
	}

	function styleNode(node, sourceClassName, itemKey){

		// Mirrors the LIVE template anchor's current classes (e.g. whether the
		// sidebar is collapsed - React toggles that as a plain class
		// attribute mutation on the existing nodes, not by replacing them),
		// then reapplies our own always-on overrides on top. Only actually
		// WRITES .className when the result differs: our own MutationObserver
		// watches class attribute changes (to catch that same collapse
		// toggle), so an unconditional write here would be a mutation that
		// re-triggers itself, forever, every animation frame.
		var classes = (sourceClassName || '').split(/\s+/).filter(function(c){
			return c && c !== 'nav-item-active';
		});

		var ownClass = CLASS_NAME + '-' + itemKey;
		if(classes.indexOf(CLASS_NAME) === -1) classes.push(CLASS_NAME);
		if(classes.indexOf(ownClass) === -1) classes.push(ownClass);

		var next = classes.join(' ');

		if(node.className !== next) node.className = next;
		if(node.hasAttribute('aria-current')) node.removeAttribute('aria-current');
	}

	function injectForAnchor(anchor){

		var inserted = 0;
		var insertPoint = anchor; // Chained: a later enabled item is inserted after the previous one, not all directly after "Websites".

		if(!insertPoint.parentNode) return 0;

		for(var i = 0; i < ITEMS.length; i++){

			var item = ITEMS[i];
			if(!item.show) continue;

			var itemClass = CLASS_NAME + '-' + item.key;

			// Existence is checked against the (stable) PARENT list, not
			// against a mark on the (possibly since-recreated-by-React)
			// anchor - React can replace the "Websites" element itself on
			// re-render, and a mark tied to that specific node would just
			// cause a fresh duplicate to be cloned each time while the old
			// one, still attached to the same parent, never gets removed.
			var existing = findDirectChild(insertPoint.parentNode, itemClass);

			if(existing){
				styleNode(existing, anchor.className, item.key);

				if(existing.previousElementSibling !== insertPoint){
					insertPoint.parentNode.insertBefore(existing, insertPoint.nextSibling);
				}

				insertPoint = existing;
				continue;
			}

			var node = anchor.cloneNode(true);

			node.href = item.href;
			node.target = '_blank';
			node.rel = 'noopener';
			styleNode(node, anchor.className, item.key);

			setLabel(node, item.label);
			setIcon(node, item.icon);
			attachTooltip(node, item.label);

			insertPoint.parentNode.insertBefore(node, insertPoint.nextSibling);

			insertPoint = node;
			inserted++;
		}

		return inserted;
	}

	function inject(){

		var insertedThisPass = 0;

		try{

			var anyEnabled = false;
			for(var i = 0; i < ITEMS.length; i++){
				if(ITEMS[i].show) anyEnabled = true;
			}
			if(!anyEnabled) return 0;

			var anchors = findAnchors();

			for(var a = 0; a < anchors.length; a++){
				insertedThisPass += injectForAnchor(anchors[a]);
			}

		}catch(e){
			// Fail silently in production: a broken selector should never
			// take the rest of the cPanel UI down with it.
		}

		return insertedThisPass;
	}

	function start(){

		try{

			var totalInserted = inject();

			var scheduled = false;
			var startTime = Date.now();

			// Only applies while we have NEVER managed a single injection - once
			// at least one has gone in, keep the (cheap - already-present items
			// are style-synced, not recreated) observer running indefinitely:
			// it is also what keeps collapsed/expanded state correct, and a
			// second nav list (e.g. a mobile drawer) can render lazily well
			// after page load.
			var GIVE_UP_MS = 30000;

			function scheduleInject(){

				if(scheduled) return; // Coalesce bursts of mutations into a single inject() per frame.
				scheduled = true;

				(window.requestAnimationFrame || window.setTimeout)(function(){
					scheduled = false;

					if(totalInserted === 0 && Date.now() - startTime > GIVE_UP_MS){
						observer.disconnect();
						return;
					}

					totalInserted += inject();
				});
			}

			var observer = new MutationObserver(scheduleInject);

			observer.observe(document.body, {
				childList: true,
				subtree: true,
				attributes: true,
				attributeFilter: ['class'] // Catches sidebar collapse/expand, which React applies as a class change on existing nodes.
			});

		}catch(e){
			// As above - never let this take the page down.
		}
	}

	if(document.body){
		start();
	}else{
		document.addEventListener('DOMContentLoaded', start);
	}

})();
Back to Directory