1// sys-popup.js â Global popup handler for the CMS. 2// Spec: docs/superpowers/specs/2026-05-19-popup-menu-and-shortcode-design.md 3// 4// Public API: 5// window.cmsOpenPopup(url, title) - open a modal showing url's #MainContent 6// window.cmsClosePopup() - close the current modal 7// 8// Triggers wired automatically: 9// - any <a data-cms-popup="1" href="..."> click 10// - ?popup=<url>[&popup_title=<title>] on page load 11// 12// Inside the modal: clicks on same-origin anchors and GET form submissions 13// swap the modal body to the new URL (wizard-style). A back arrow in the 14// header pops the history stack. Modal only fully closes on Ã, ESC, or 15// backdrop click. 16// 17// Security: only same-origin URLs are honored. 18 19(function () { 20 'use strict'; 21 22 var state = { 23 overlay: null, 24 lastTrigger: null, 25 history: [], // stack of { url, title } for the back button 26 currentUrl: null, 27 currentTitle: '' 28 }; 29 30 function isSameOrigin(url) { 31 try { 32 var u = new URL(url, window.location.origin); 33 return u.origin === window.location.origin; 34 } catch (e) { return false; } 35 } 36 37 function escapeHtml(s) { 38 return String(s || '').replace(/[&<>"']/g, function (c) { 39 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; 40 }); 41 } 42 43 // Inline SVG icons -- predictable centering (no font-baseline drift) and 44 // strokeWidth gives them a "bold" look without depending on font-weight. 45 var BACK_ICON_SVG = 46 '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" ' + 47 'stroke="currentColor" stroke-width="3" stroke-linecap="round" ' + 48 'stroke-linejoin="round" aria-hidden="true">' + 49 '<path d="M15 6 L9 12 L15 18"/>' + 50 '</svg>'; 51 var CLOSE_ICON_SVG = 52 '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" ' + 53 'stroke="currentColor" stroke-width="3" stroke-linecap="round" ' + 54 'stroke-linejoin="round" aria-hidden="true">' + 55 '<path d="M6 6 L18 18 M18 6 L6 18"/>' + 56 '</svg>'; 57 58 // Native <dialog> + showModal() puts the popup in the browser's top layer, 59 // bypassing any ancestor transforms/filters that would otherwise pin a 60 // position:fixed overlay to a non-viewport containing block. This is what 61 // makes the popup actually viewport-centered on layouts that wrap their 62 // body in a transformed container (as the host site does). 63 function buildSkeleton(title) { 64 var dlg = document.createElement('dialog'); 65 dlg.className = 'cms-popup-dialog'; 66 dlg.innerHTML = 67 '<div class="cms-popup-header">' + 68 '<button class="cms-popup-back" aria-label="Back" type="button" hidden>' + BACK_ICON_SVG + '</button>' + 69 '<h2 class="cms-popup-title">' + escapeHtml(title || '') + '</h2>' + 70 '<button class="cms-popup-close" aria-label="Close" type="button">' + CLOSE_ICON_SVG + '</button>' + 71 '</div>' + 72 '<div class="cms-popup-body"><div class="cms-popup-loading">Loadingâ¦</div></div>'; 73 return dlg; 74 } 75 76 function updateBackButton() { 77 if (!state.overlay) return; 78 var back = state.overlay.querySelector('.cms-popup-back'); 79 if (!back) return; 80 if (state.history.length > 0) back.removeAttribute('hidden'); 81 else back.setAttribute('hidden', ''); 82 } 83 84 function closePopup() { 85 if (!state.overlay) return; 86 var dlg = state.overlay; 87 state.overlay = null; 88 state.history = []; 89 state.currentUrl = null; 90 state.currentTitle = ''; 91 document.body.classList.remove('cms-popup-open'); 92 // close() if it's still open; the 'close' listener (set in openPopup) 93 // will unmount the element from the DOM. 94 if (dlg.open && typeof dlg.close === 'function') { 95 try { dlg.close(); } catch (e) { if (dlg.parentNode) dlg.parentNode.removeChild(dlg); } 96 } else if (dlg.parentNode) { 97 dlg.parentNode.removeChild(dlg); 98 } 99 if (state.lastTrigger && typeof state.lastTrigger.focus === 'function') { 100 try { state.lastTrigger.focus(); } catch (e) {} 101 } 102 // A reCAPTCHA-protected form rendered in the popup loads gre
102captcha, which 103 // appends its floating badge to document.body. The badge-hiding <style> 104 // lived inside the popup content (now removed), so the badge would reappear 105 // visibly after close. Remove the leaked badge(s) -- the host page itself 106 // doesn't use reCAPTCHA, so this only clears what the popup introduced. 107 try { 108 var badges = document.querySelectorAll('.grecaptcha-badge'); 109 for (var b = 0; b < badges.length; b++) { 110 if (badges[b].parentNode) badges[b].parentNode.removeChild(badges[b]); 111 } 112 } catch (e) {} 113 } 114 115 // Try module-specific wrappers first (they contain ONLY the module payload, 116 // no page chrome), then #MainContent (which on some layouts wraps too much 117 // and brings in sub-headers and side columns), then <body> as a last resort. 118 var POPUP_CONTENT_SELECTORS = [ 119 '[data-cms-popup-root]', // explicit opt-in -- highest priority 120 '[data-cms-zone="main"]', // modern block-based layouts -- the main 121 // content column; chrome (top-links, social, 122 // logo, nav) lives outside any data-cms-zone, 123 // sidebar lives in data-cms-zone="sidebar". 124 '.directory-container', 125 '.calendar-container', 126 '.classifieds-container', 127 '.properties-container', 128 '.shop-container', 129 '.article-container', 130 '.events-container', 131 '.form-container', // src/forms/index.js -- custom Submit forms 132 '.form-container-wrapper' // src/services/view/* -- view/edit/details 133 ]; 134 135 // Collect <link rel=stylesheet> and <style> blocks that are siblings of the 136 // matched wrapper inside its parent (each module generator emits its CSS 137 // alongside the container -- we'd drop the styling without these). Browsers 138 // honor stylesheet links inserted via innerHTML, so this works at runtime. 139 function collectSiblingStyles(el) { 140 var parent = el.parentNode; 141 if (!parent) return ''; 142 var out = ''; 143 var siblings = parent.children; 144 for (var i = 0; i < siblings.length; i++) { 145 var s = siblings[i]; 146 if (s === el) continue; 147 var tag = s.tagName ? s.tagName.toUpperCase() : ''; 148 if (tag === 'LINK' || tag === 'STYLE') out += s.outerHTML; 149 } 150 return out; 151 } 152 153 // Wait for any <link rel=stylesheet> in `container` to finish loading. 154 // Without this the inserted module content paints unstyled for a frame or 155 // two while its CSS is still in flight (FOUC). We resolve on each link's 156 // load OR error, and cap the total wait so a broken stylesheet doesn't 157 // keep the popup spinner hanging forever. 158 function waitForStylesheets(container, timeoutMs) { 159 var links = container.querySelectorAll('link[rel="stylesheet"]'); 160 if (!links.length) return Promise.resolve(); 161 var promises = []; 162 for (var i = 0; i < links.length; i++) { 163 (function (link) { 164 // Already loaded? .sheet is non-null and readable -> done. 165 try { if (link.sheet) { promises.push(Promise.resolve()); return; } } catch (_) {} 166 promises.push(new Promise(function (resolve) { 167 var done = false; 168 var finish = function () { if (done) return; done = true; resolve(); }; 169 link.addEventListener('load', finish); 170 link.addEventListener('error', finish); 171 setTimeout(finish, timeoutMs || 1500); 172 })); 173 })(links[i]); 174 } 175 return Promise.all(promises); 176 } 177 178 // Scripts inserted via innerHTML are parsed but NEVER executed by the browser. 179 // Module payloads (the form submit handler unifiedSubmitForm, spam protection, 180 // AI assist, etc.) ship as inline <script> blocks, so without this they wire up 181 // nothing and the form silently fails to submit. Re-create each <script> as a 182 // fresh element so it runs. Only the fragment's own scripts are present here 183 // (head libraries live outside the extracted wrapper), so this stays cheap. 184 function executeScripts(container) { 185 var scripts = container.querySelectorAll('script'); 186 // Legacy CMS layouts render their fragments through inline calls like
187 // AdGroup6(), Section1(), GetLogo() -- each emitted as 188 // `function X(){document.write(`â¦`)}`. When such a fragment is pulled into 189 // the popup, re-running that script calls document.write() AFTER the host 190 // page has finished loading, which implicitly fires document.open() and 191 // WIPES the entire current document -- the popup and the page behind it 192 // both go blank. Guard document.write/writeln for the duration of this 193 // batch and redirect any output to the executing script's own position, so 194 // the markup lands inside the popup instead of nuking the page. 195 var origWrite = document.write; 196 var origWriteln = document.writeln; 197 var writeTarget = null; // the <script> currently executing 198 function patchedWrite() { 199 var html = Array.prototype.join.call(arguments, ''); 200 if (writeTarget && writeTarget.parentNode) { 201 try { writeTarget.insertAdjacentHTML('beforebegin', html); } catch (e) {} 202 } 203 } 204 try { 205 document.write = patchedWrite; 206 document.writeln = patchedWrite; 207 for (var i = 0; i < scripts.length; i++) { 208 var old = scripts[i]; 209 var type = (old.getAttribute('type') || '').toLowerCase(); 210 // Skip data/template blocks (JSON-LD, x-template, etc.) -- only run JS. 211 if (type && !/^(text\/javascript|application\/javascript|module)$/.test(type)) continue; 212 var s = document.createElement('script'); 213 for (var a = 0; a < old.attributes.length; a++) { 214 s.setAttribute(old.attributes[a].name, old.attributes[a].value); 215 } 216 s.textContent = old.textContent; 217 if (!old.parentNode) continue; 218 // Insert the live script BEFORE the placeholder (so it already has a 219 // parent -- patchedWrite needs one for insertAdjacentHTML), which runs 220 // it synchronously for inline scripts, then drop the placeholder. 221 writeTarget = s; 222 old.parentNode.insertBefore(s, old); 223 old.parentNode.removeChild(old); 224 } 225 } finally { 226 document.write = origWrite; 227 document.writeln = origWriteln; 228 writeTarget = null; 229 } 230 } 231 232 // In a popup the form is rendered on a HOST page, so the form's submit handler 233 // default target (window.location.pathname) points at the host page, not the 234 // form's own URL. Stamp the real form path onto every <form> so the handler 235 // (cmsResolvePostUrl in TemplateProcessor) POSTs to the right endpoint. 236 function stampFormAction(container, sourceUrl) { 237 try { 238 var path = new URL(sourceUrl, window.location.origin).pathname; 239 var forms = container.querySelectorAll('form'); 240 for (var i = 0; i < forms.length; i++) { 241 if (!forms[i].getAttribute('data-form-action')) { 242 forms[i].setAttribute('data-form-action', path); 243 } 244 } 245 } catch (e) {} 246 } 247 248 function extractMainContent(html) { 249 var doc; 250 try { 251 doc = new DOMParser().parseFromString(html, 'text/html'); 252 } catch (e) { return null; } 253 for (var i = 0; i < POPUP_CONTENT_SELECTORS.length; i++) { 254 var el = doc.querySelector(POPUP_CONTENT_SELECTORS[i]); 255 if (el && el.innerHTML.trim()) { 256 return { 257 html: collectSiblingStyles(el) + el.outerHTML, 258 title: doc.title || '' 259 }; 260 } 261 } 262 var main = doc.getElementById('MainContent'); 263 if (main && main.innerHTML.trim()) return { html: main.innerHTML, title: doc.title || '' }; 264 var body = doc.body; 265 if (body && body.innerHTML.trim()) return { html: body.innerHTML, title: doc.title || '' }; 266 return null; 267 } 268 269 // Render a URL into the existing modal body. When pushHistory is true and 270 // we already had a URL loaded, push that previous one onto the back stack. 271 // On any failure, fall back to a full-page navigation rather than leaving 272 // the user staring at a half-loaded modal. 273 function renderUrl(url, title, pushHistory) { 274 if (!state.overlay) return; 275 if (pushHistory && state.currentUrl) { 276 state.history.push({ url: state.currentUrl, title: state.currentTitle }); 277 } 278 state.currentUrl = url; 279 state.currentTitle = title || ''; 280 281 var bodyEl = state.overlay.querySelector('.cms-popup-body'); 282 var titleEl = state.overlay.querySelector('.cms-popup-title'); 283 bodyEl.innerHTML = '<div class="cms-popup-loading">Loadingâ¦</div>'; 284 titleEl.textContent = state.currentTitle; 285 updateBackButton(); 286 287 fetch(url, { credentials: 'same-origin' }) 288 .then(function (resp) { 289 if (!resp.ok) throw new Error('HTTP ' + resp.status); 290 return resp.text(); 291 }) 292 .then(function (html) { 293 var extracted = extractMainContent(html); 294 if (!extracted) throw new Error('no extractable content'); 295 // Only mutate if the modal is still open and still showing this URL 296 // (user may have clicked back or closed during the fetch). 297 if (!state.overlay || state.currentUrl !== url) return; 298 if (!title && extracted.title) { 299 state.currentTitle = extracted.title; 300 titleEl.textContent = extracted.title; 301 } 302 // Stage content hidden while its <link rel=stylesheet> tags load, 303 // keep the spinner visible, then swap. Avoids the unstyled flash. 304 var stage = document.createElement('div'); 305 stage.className = 'cms-popup-stage'; 306 stage.style.visibility = 'hidden'; 307 bodyEl.appendChild(stage); 308 stage.innerHTML = extracted.html; 309 // innerHTML doesn't run <script> tags -- stamp the form's real URL, then 310 // re-execute its scripts so the submit handler actually wires up. 311 stampFormAction(stage, url); 312 executeScripts(stage); 313 waitForStylesheets(stage, 1500).then(function () { 314 if (!state.overlay || state.currentUrl !== url) return; 315 var loader = bodyEl.querySelector('.cms-popup-loading'); 316 if (loader && loader.parentNode) loader.parentNode.removeChild(loader); 317 stage.style.visibility = ''; 318 }); 319 }) 320 .catch(function () { 321 closePopup(); 322 window.location.href = url; 323 }); 324 } 325 326 function goBack() { 327 if (state.history.length === 0) return; 328 var prev = state.history.pop(); 329 renderUrl(prev.url, prev.title, false); 330 } 331 332 // Should an in-popup click be intercepted? Same rules as the outer handler 333 // -- modifier keys, middle-click, download attribute, off-origin, and 334 // mailto:/tel:/javascript: hrefs all pass through to the browser. 335 function shouldInterceptAnchor(e, a) { 336 if (e.button !== 0) return false; 337 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return false; 338 if (a.target === '_blank' || a.target === '_top') return false; 339 if (a.hasAttribute('download')) return false; 340 var href = a.getAttribute('href'); 341 if (!href || href.charAt(0) === '#') return false; 342 if (/^(mailto:|tel:|javascript:)/i.test(href)) return false; 343 if (!a.href || !isSameOrigin(a.href)) return false; 344 return true; 345 } 346 347 function handleInnerClick(e) { 348 if (!state.overlay) return; 349 var bodyEl = state.overlay.querySelector('.cms-popup-body'); 350 if (!bodyEl || !bodyEl.contains(e.target)) return; 351 var a = e.target.closest && e.target.closest('a'); 352 if (!a) return; 353 if (!shouldInterceptAnchor(e, a)) return; 354 e.preventDefault(); 355 var title = a.getAttribute('data-popup-title') || (a.textContent || '').trim(); 356 renderUrl(a.href, title, true); 357 } 358 359 // Keep GET form submissions inside the modal too (the directory search box 360 // is GET; classifieds/calendar searches likewise). POST forms pass through. 361 function handleInnerSubmit(e) { 362 if (!state.overlay) return; 363 var bodyEl = state.overlay.querySelector('.cms-popup-body'); 364 if (!bodyEl || !bodyEl.contains(e.target)) return; 365 var form = e.target; 366 if (!form || form.tagName !== 'FORM') return; 367 var method = (form.getAttribute('method') || 'GET').toUpperCase(); 368 if (method !== 'GET') return; 369 var action = form.getAttribute('action') || state.currentUrl || window.location.href; 370 var resolved; 371 try { resolved = new URL(action, window.location.href); } catch (_) { return; } 372 if (resolved.origin !== window.location.origin) return; 373 e.preventDefault(); 374 var params = new URLSearchParams(new FormData(form)); 375 // Strip out any unnamed/empty submitter entries 376 var qs = params.toString(); 377 var sep = resolved.search ? '&' : '?'; 378 var fullUrl = resolved.origin + resolved.pathname + resolved.search + (qs ? sep + qs : '') + resolved.hash; 379 renderUrl(fullUrl, state.currentTitle, true); 380 } 381 382 function openPopup(url, title) { 383 if (!url || !isSameOrigin(new URL(url, window.location.origin).href)) return; 384 385 if (state.overlay) closePopup(); 386 387 state.history = []; 388 state.currentUrl = null; 389 state.currentTitle = ''; 390 391 var dlg = buildSkeleton(title);
392 state.overlay = dlg; 393 document.body.appendChild(dlg); 394 document.body.classList.add('cms-popup-open'); 395 396 var closeBtn = dlg.querySelector('.cms-popup-close'); 397 var backBtn = dlg.querySelector('.cms-popup-back'); 398 closeBtn.addEventListener('click', closePopup); 399 backBtn.addEventListener('click', goBack); 400 401 // Click on the backdrop (the dialog element itself, outside its visible 402 // box) closes the modal. With <dialog>, ::backdrop clicks bubble up to 403 // the dialog element, so we detect by checking whether the click point 404 // is inside the dialog's rendered rect. 405 dlg.addEventListener('click', function (e) { 406 if (e.target !== dlg) return; 407 var rect = dlg.getBoundingClientRect(); 408 var inside = e.clientX >= rect.left && e.clientX <= rect.right 409 && e.clientY >= rect.top && e.clientY <= rect.bottom; 410 if (!inside) closePopup(); 411 }); 412 dlg.addEventListener('click', handleInnerClick); 413 dlg.addEventListener('submit', handleInnerSubmit); 414 415 // ESC fires the 'cancel' event; default behavior is to close. We want 416 // our full cleanup, so route both 'cancel' and 'close' through closePopup, 417 // and remove the element from the DOM on actual close. 418 dlg.addEventListener('cancel', function (e) { 419 e.preventDefault(); 420 closePopup(); 421 }); 422 dlg.addEventListener('close', function () { 423 if (dlg.parentNode) dlg.parentNode.removeChild(dlg); 424 }); 425 426 // showModal() puts the dialog in the browser's top layer -- guaranteed 427 // to be viewport-positioned regardless of ancestor transforms. Some old 428 // browsers may not support it; fall back to appending visibly. 429 if (typeof dlg.showModal === 'function') { 430 try { dlg.showModal(); } catch (e) { dlg.setAttribute('open', ''); } 431 } else { 432 dlg.setAttribute('open', ''); 433 } 434 435 try { closeBtn.focus(); } catch (e) {} 436 437 renderUrl(url, title, false); 438 } 439 440 // Delegated click handler. Plain left-clicks on a[data-cms-popup="1"] open 441 // the modal regardless of any target=_blank/_top inherited from the menu's 442 // link_window setting -- the popup flag is the user's explicit intent. 443 // Modifier keys and middle-click still bail so power users keep the 444 // "open in new tab" escape hatch via Ctrl/Cmd-click / middle-click. 445 document.addEventListener('click', function (e) { 446 if (e.button !== 0) return; 447 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; 448 var a = e.target.closest && e.target.closest('a[data-cms-popup="1"]'); 449 if (!a) return; 450 if (a.hasAttribute('download')) return; 451 if (!isSameOrigin(a.href)) return; 452 e.preventDefault(); 453 state.lastTrigger = a; 454 var title = a.getAttribute('data-popup-title') || (a.textContent || '').trim(); 455 openPopup(a.href, title); 456 }); 457 458 // ESC closes 459 document.addEventListener('keydown', function (e) { 460 if (e.key === 'Escape' && state.overlay) closePopup(); 461 }); 462 463 // URL parameter â auto-open on page load 464 function autoOpenFromUrl() { 465 try { 466 var params = new URLSearchParams(window.location.search); 467 var url = params.get('popup'); 468 if (!url) return; 469 var resolved = new URL(url, window.location.origin).href; 470 if (!isSameOrigin(resolved)) return; 471 openPopup(resolved, params.get('popup_title') || ''); 472 } catch (e) {} 473 } 474 if (document.readyState === 'loading') { 475 document.addEventListener('DOMContentLoaded', autoOpenFromUrl); 476 } else { 477 autoOpenFromUrl(); 478 } 479 480 window.cmsOpenPopup = openPopup; 481 window.cmsClosePopup = closePopup; 482})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.