1/* ============================================================================ 2 esh-templates.js -- behavior for AI-generated article + product content. 3 4 Deployed platform-wide alongside esh-templates.css. It lives OUTSIDE every 5 editable field, so a seller cannot delete it. It attaches only to `esh-*` 6 hooks and does nothing when they are absent -- safe to load on every page. 7 8 No dependencies. ES5-safe for legacy storefronts. 9 10 Progressive enhancement: FAQ answers are visible until this script marks the 11 block ready, so a script failure degrades to plain expanded content rather 12 than permanently hidden answers. 13 ============================================================================ */ 14(function () { 15 'use strict'; 16 17 function ready(fn) { 18 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); 19 else fn(); 20 } 21 22 function scrollY() { return window.pageYOffset || document.documentElement.scrollTop || 0; } 23 function viewH() { return window.innerHeight || document.documentElement.clientHeight; } 24 function docH() { 25 var b = document.body, d = document.documentElement; 26 return Math.max(b.scrollHeight, b.offsetHeight, d.scrollHeight, d.offsetHeight); 27 } 28 29 /* == FAQ -- shared by articles and products =============================== */ 30 function initFaq(block) { 31 var qs = [].slice.call(block.querySelectorAll('.esh-faq__q')); 32 if (!qs.length) return; 33 34 qs.forEach(function (q, i) { 35 var panel = q.nextElementSibling; 36 if (!panel || panel.className.indexOf('esh-faq__a') < 0) return; 37 if (!panel.id) panel.id = (block.id || 'esh-faq') + '-a' + (i + 1); 38 q.setAttribute('aria-controls', panel.id); 39 var open = i === 0; // first item open, as designed 40 q.setAttribute('aria-expanded', open ? 'true' : 'false'); 41 panel.hidden = !open; 42 43 q.addEventListener('click', function () { 44 var isOpen = q.getAttribute('aria-expanded') === 'true'; 45 qs.forEach(function (o) { // single-open accordion 46 var p = document.getElementById(o.getAttribute('aria-controls')); 47 o.setAttribute('aria-expanded', 'false'); 48 if (p) p.hidden = true; 49 }); 50 if (!isOpen) { 51 q.setAttribute('aria-expanded', 'true'); 52 panel.hidden = false; 53 } 54 }); 55 }); 56 block.setAttribute('data-ready', ''); // releases the no-JS CSS fallback 57 } 58 59 /* == ARTICLE ============================================================= */ 60 function initArticle(root) { 61 var main = root.querySelector('.esh-art__main'); 62 var tocList = root.querySelector('.esh-toc__list'); 63 var bar = root.querySelector('.esh-art__progress-bar'); 64 var pctEls = [].slice.call(root.querySelectorAll('.esh-toc__pct')); 65 var side = root.querySelector('.esh-art__side'); 66 var OFFSET = 140, JUMP = 80; 67 68 /* ---- site sticky header ------------------------------------------------- 69 Platinum pins #sticky_header (and shops pin their own navs/promo bars). 70 It starts hidden and only gains height once you scroll, so a one-time 71 measurement at load reads 0 and our sidebar then slides underneath it. 72 Measure what is ACTUALLY covering the top of the viewport, on every 73 scroll/resize, and publish it as --esh-sticky-top. Selector-free, so it 74 works on any shop without knowing its markup. */ 75 var stickyTop = 0; 76 77 // Does this computed style PAINT a fill down to its box bottom? A transparent WRAPPER (the 78 // common case: an off-canvas menu shell, a full-height nav container) paints nothing, so its 79 // box bottom must NOT count -- measuring the wrapper box is what overshot the real header on 80 // some tenants (e.g. a 175px transparent shell around a header that visibly ends ~156px). 81 function _paints(cs) { 82 var bg = cs.backgroundColor || ''; 83 var transparent = bg === 'transparent' || /rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)/.test(bg); 84 if (!transparent) return true; 85 if ((cs.backgroundImage || 'none') !== 'none') return true; 86 var bw = parseFloat(cs.borderBottomWidth || 0); 87 return bw > 0 && cs.borderBottomStyle !== 'none'; 88 } 89 90 // The site's top-pinned fixed/sticky headers, cached (recomputed on resize). Collected with 91 // querySelectorAll, NOT elementsFromPoint -- a real header bar often has pointer-events:none 92 // (so clicks pass through), which makes elementsFromPoint skip it entirely. 93 var stickyWraps = []; 94 function collectStickyWraps() { 95 stickyWraps = []; 96 var all = document.querySelectorAll('*'), i, cs; 97 for (i = 0; i < all.length; i++) { 98 if (root.contains(all[i])) continue; // never our own chrome 99 cs = getComputedStyle(all[i]); 100 if (cs.position === 'fixed' || cs.position === 'sticky') stickyWraps.push(all[i]); 101 } 102 } 103 104 // Header height = the DEEPEST PAINTING element within any top-pinned fixed/sticky container 105 // (the container itself or a descendant). This measures the real painted bottom of the header 106 // per tenant, ignoring transparent wrapper padding, and works whatever the markup is. 107 // Horizontally inside the viewport? A CLOSED off-canvas panel (slide-out menu drawer, side 108 // cart) is fixed at top:0 and full height but parked off-screen left/right -- it covers 109 // nothing, and counting it read cell-tec's closed menu drawer as a 531px "header". 110 function _onScreenX(r, vw) { return r.right > 0 && r.left < vw; } 111 112 function measureStickyTop() { 113 var vh = window.innerHeight || 0, lim = vh * 0.6, best = 0, i, j, w, wcs, wr, nodes, e, cs, r; 114 var vw = window.innerWidth || document.documentElement.clientWidth || 0; 115 for (i = 0; i < stickyWraps.length; i++) { 116 w = stickyWraps[i]; wcs = getComputedStyle(w); 117 if (wcs.display === 'none' || wcs.visibility === 'hidden' || wcs.opacity === '0') continue; 118 wr = w.getBoundingClientRect(); 119 if (wr.top > 1 || wr.height <= 0 || wr.top > lim) continue; // must be pinned at the top 120 if (!_onScreenX(wr, vw)) continue; // closed off-canvas panel 121 nodes = w.querySelectorAll('*'); 122 for (j = -1; j < nodes.length; j++) { 123 e = (j < 0) ? w : nodes[j]; 124 cs = getComputedStyle(e); 125 if (cs.visibility === 'hidden' || cs.display === 'none') continue; 126 if (!_paints(cs)) continue; 127 r = e.getBoundingClientRect(); 128 if (r.height <= 0 || r.width <= 0 || r.bottom <= 0 || r.bottom > lim) continue;
129 if (!_onScreenX(r, vw)) continue; // painted, but off-screen 130 if (r.bottom > best) best = r.bottom; 131 } 132 } 133 return Math.round(best); 134 } 135 136 var _lastMeasure = 0; 137 function syncStickyTop() { 138 // Per-tenant escape hatch: a shop can pin an exact offset in its own CSS with 139 // .esh-art { --esh-sticky-top-manual: 156px } 140 // It wins over the auto scan (which reads live geometry but can't know a theme's intent). 141 var manual = getComputedStyle(root).getPropertyValue('--esh-sticky-top-manual').trim(); 142 if (manual) { 143 if (manual !== root.style.getPropertyValue('--esh-sticky-top')) 144 root.style.setProperty('--esh-sticky-top', manual); 145 return; 146 } 147 // measureStickyTop() walks the header subtrees -- too heavy for every scroll frame. The 148 // header height is near-static, so throttle to ~200ms; onScroll still fires for progress. 149 var now = (Date.now ? Date.now() : +new Date()); 150 if (now - _lastMeasure < 200) return;
151 _lastMeasure = now; 152 var v = measureStickyTop(); 153 if (v === stickyTop) return; 154 stickyTop = v; 155 root.style.setProperty('--esh-sticky-top', v + 'px'); 156 } 157 158 /* -- TOC built from the H2s inside PageContent ------------------------- */ 159 var heads = main ? [].slice.call(main.querySelectorAll('h2')) : []; 160 var links = []; 161 if (tocList) { 162 heads.forEach(function (h, i) { 163 if (!h.id) h.id = 'esh-sec-' + (i + 1); 164 var li = document.createElement('li'); 165 var a = document.createElement('a'); 166 a.className = 'esh-toc__link'; 167 a.href = '#' + h.id; 168 a.textContent = (h.textContent || '').trim(); 169 a.addEventListener('click', function (e) { 170 e.preventDefault(); 171 var top = h.getBoundingClientRect().top + scrollY() - (JUMP + stickyTop); 172 try { window.scrollTo({ top: top, behavior: 'smooth' }); } 173 catch (err) { window.scrollTo(0, top); } 174 }); 175 li.appendChild(a); tocList.appendChild(li); links.push(a); 176 }); 177 if (!heads.length) { 178 var nav = tocList.closest ? tocList.closest('.esh-toc') : tocList.parentNode; 179 if (nav) nav.style.display = 'none'; 180 } 181 } 182 183 /* -- active section ------------------------------------------------------- 184 R1 normal scan : last heading whose top passed OFFSET. 185 R2 bottom guard : at the very bottom the LAST item is always active. 186 R3 tail guard : headings the document cannot scroll far enough to reach 187 share the remaining range proportionally. Without it, 188 short trailing sections are skipped entirely -- and when 189 NOT EVEN THE FIRST heading is reachable (a short article 190 on a tall screen) everything would stick on item 1. */ 191 function activeIndex() { 192 if (!heads.length) return -1; 193 var y = scrollY(), maxScroll = Math.max(1, docH() - viewH()), i; 194 var acts = heads.map(function (h) { 195 return h.getBoundingClientRect().top + y - (OFFSET + stickyTop); 196 }); 197 198 var lastReachable = -1; 199 for (i = 0; i < acts.length; i++) if (acts[i] <= maxScroll) lastReachable = i; 200 201 if (lastReachable === acts.length - 1) { 202 var a = 0; 203 for (i = 0; i < acts.length; i++) if (y >= acts[i]) a = i; 204 if (y >= maxScroll - 2) a = acts.length - 1; 205 return a; 206 } 207 var base = lastReachable < 0 ? 0 : lastReachable; 208 var startY = lastReachable < 0 ? 0 : acts[lastReachable]; 209 if (y < startY) { 210 var b = 0; 211 for (i = 0; i <= base; i++) if (y >= acts[i]) b = i; 212 return b; 213 } 214 var tail = acts.length - base; 215 var frac = (y - startY) / Math.max(1, maxScroll - startY); 216 return Math.min(acts.length - 1, base + Math.floor(frac * tail)); 217 } 218 219 function progress() { 220 if (!main) return 0; 221 var y = scrollY(); 222 var top = main.getBoundingClientRect().top + y; 223 var span = Math.max(1, main.offsetHeight - viewH() * 0.6); 224 return Math.max(0, Math.min(100, ((y - top + viewH() * 0.25) / span) * 100)); 225 } 226 227 var lastIdx = -2, ticking = false; 228 function measure() { 229 ticking = false; 230 syncStickyTop(); 231 var p = Math.round(progress()); 232 if (bar) bar.style.width = p + '%'; 233 pctEls.forEach(function (el) { el.textContent = p + '% \u05e0\u05e7\u05e8\u05d0'; }); 234 235 var idx = activeIndex(); 236 if (idx === lastIdx) return; 237 lastIdx = idx; 238 links.forEach(function (a, i) { 239 var on = i === idx; 240 if (on) { a.className = 'esh-toc__link is-active'; a.setAttribute('aria-current', 'true'); } 241 else { a.className = 'esh-toc__link'; a.removeAttribute('aria-current'); } 242 }); 243 // keep the active item visible inside the internally-scrolling sidebar 244 if (idx >= 0 && links[idx] && side && side.scrollHeight > side.clientHeight) { 245 var l = links[idx].getBoundingClientRect(), s = side.getBoundingClientRect(); 246 if (l.top < s.top || l.bottom > s.bottom) { 247 try { links[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {} 248 } 249 } 250 } 251 function onScroll() { 252 if (ticking) return; 253 ticking = true; 254 (window.requestAnimationFrame || function (f) { setTimeout(f, 16); })(measure); 255 } 256 window.addEventListener('scroll', onScroll, { passive: true });
257 window.addEventListener('resize', function () { collectStickyWraps(); onScroll(); }); 258 if (window.ResizeObserver && main) new ResizeObserver(onScroll).observe(main); 259 // Re-scan the sticky headers after late assets settle (webfonts, lazy header widgets) 260 // could change the header height after first paint. 261 setTimeout(function () { collectStickyWraps(); syncStickyTop(); }, 400); 262 window.addEventListener('load', function () { collectStickyWraps(); syncStickyTop(); }); 263 264 /* -- mobile TOC burger -------------------------------------------------- */ 265 var toc = root.querySelector('.esh-toc'); 266 var burger = root.querySelector('.esh-toc__burger'); 267 if (toc && burger) { 268 burger.addEventListener('click', function () { 269 var open = burger.getAttribute('aria-expanded') === 'true'; 270 burger.setAttribute('aria-expanded', open ? 'false' : 'true'); 271 toc.className = open ? 'esh-card esh-toc' : 'esh-card esh-toc is-open'; 272 }); 273 // Jumping to a section should close the sheet, or the target sits behind it. 274 links.forEach(function (a) { 275 a.addEventListener('click', function () { 276 if (burger.offsetParent === null) return; // desktop: button hidden 277 burger.setAttribute('aria-expanded', 'false'); 278 toc.className = 'esh-card esh-toc'; 279 }); 280 }); 281 } 282 283 /* -- share -------------------------------------------------------------- */ 284 var url = location.href, title = document.title; 285 var wa = root.querySelector('.esh-share__btn--wa'); 286 var fb = root.querySelector('.esh-share__btn--fb'); 287 if (wa) wa.href = 'https://wa.me/?text=' + encodeURIComponent(title + ' ' + url); 288 if (fb) fb.href = 'https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url); 289 290 var copyBtn = root.querySelector('.esh-share__btn--copy'); 291 var timer; 292 if (copyBtn) copyBtn.addEventListener('click', function () { 293 // Icon-only confirmation: swap the link glyph for a check, then revert. 294 // No text label, so the sidebar never reflows. 295 function done() { 296 copyBtn.className = 'esh-share__btn esh-share__btn--copy is-done'; 297 copyBtn.setAttribute('aria-label', '\u05d4\u05e7\u05d9\u05e9\u05d5\u05e8 \u05d4\u05d5\u05e2\u05ea\u05e7'); 298 clearTimeout(timer); 299 timer = setTimeout(function () { 300 copyBtn.className = 'esh-share__btn esh-share__btn--copy'; 301 copyBtn.setAttribute('aria-label', '\u05d4\u05e2\u05ea\u05e7\u05ea \u05e7\u05d9\u05e9\u05d5\u05e8'); 302 }, 2000); 303 } 304 function fallback() { // legacy / non-HTTPS storefronts 305 var t = document.createElement('textarea'); 306 t.value = location.href; t.setAttribute('readonly', ''); 307 t.style.cssText = 'position:absolute;left:-9999px'; 308 document.body.appendChild(t); t.select(); 309 try { document.execCommand('copy'); done(); } catch (e) {} 310 document.body.removeChild(t); 311 } 312 if (navigator.clipboard && window.isSecureContext) { 313 navigator.clipboard.writeText(location.href).then(done, fallback); 314 } else fallback(); 315 }); 316 317 /* -- tables: horizontal-scroll wrapper --------------------------------- 318 The SEO transform emits generated tables already inside .esh-table-wrap, 319 but a seller can PASTE or edit a bare <table> after publish. Wrap any 320 article-body table not already wrapped, so wide tables scroll instead of 321 breaking the column. ES5-safe, null-safe, no deps. */ 322 if (main) {
323 [].slice.call(main.querySelectorAll('table')).forEach(function (t) { 324 var p = t.parentNode; 325 if (!p) return; 326 var cn = '' + (p.className || ''); 327 if (cn.indexOf('esh-table-wrap') >= 0) return; // already wrapped 328 var wrap = document.createElement('div'); 329 wrap.className = 'esh-table-wrap'; 330 p.insertBefore(wrap, t); 331 wrap.appendChild(t); 332 }); 333 } 334 335 collectStickyWraps(); // populate the header set before the first measure 336 measure(); 337 } 338 339 /* == boot ================================================================ */ 340 /* == brand contrast guard ================================================ 341 The shop's --esh-brand is arbitrary. White text on a light brand (yellow, 342 lime, pale pink) is unreadable, so pick the on-brand foreground from the 343 colour's relative luminance. CSS defaults --esh-on-brand to #fff, so a JS 344 failure degrades to today's behaviour rather than to something broken. */ 345 function brandContrast() { 346 var probe = document.querySelector('.esh-art, .esh-pdx'); 347 if (!probe) return; 348 var acc = getComputedStyle(probe).getPropertyValue('--esh-acc').trim(); 349 if (!acc) return; 350 var d = document.createElement('span'); 351 d.style.cssText = 'color:' + acc + ';position:absolute;left:-9999px'; 352 document.body.appendChild(d); 353 var m = getComputedStyle(d).color.match(/[\d.]+/g); 354 document.body.removeChild(d); 355 if (!m) return; 356 var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; 357 var L = 0.2126 * f(+m[0]) + 0.7152 * f(+m[1]) + 0.0722 * f(+m[2]); 358 // contrast of white-on-brand; below 4.5:1 switch to ink 359 var white = (1.05) / (L + 0.05); 360 probe.style.setProperty('--esh-on-brand', white < 4.5 ? '#0f172a' : '#fff'); 361 document.querySelectorAll('.esh-art, .esh-pdx').forEach(function (el) { 362 el.style.setProperty('--esh-on-brand', white < 4.5 ? '#0f172a' : '#fff'); 363 }); 364 } 365 366 ready(function () { 367 brandContrast(); 368 [].slice.call(document.querySelectorAll('.esh-faq')).forEach(initFaq); 369 var art = document.querySelector('.esh-art'); 370 if (art) initArticle(art); 371 }); 372})();
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.