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