1/** 2 * PPAccess restriction resolver. 3 * 4 * Server ships full content (hidden via inline visibility:hidden CSS for 5 * non-subscribers), this script reads viewed_content from localStorage, asks 6 * the REST endpoint for a 7 * decision, then either reveals the container or replaces it with the paywall prompt. 8 * 9 * Two layouts are supported: 10 * - inline: paywall prompt replaces the article container (default). 11 * - modal: paywall prompt mounts in a fixed-position overlay; the article stays 12 * visible behind the modal and JS locks page scroll. 13 * 14 * @package PaywallProjectAccess 15 */ 16(function () { 17 'use strict'; 18 19 var cfg = window.ppaccessResolver || {}; 20 if (!cfg.rest_url) { return; } 21 22 // Refresh nonce on load so cached pages don't send a stale one. 23 // Only needed for logged-in users; anonymous requests skip the nonce anyway. 24 if (document.body && document.body.classList.contains('logged-in') && cfg.nonce) { 25 var nonceBase = cfg.rest_url.replace(/\/public\/.*$/, ''); 26 fetch(nonceBase + '/public/nonce', { method: 'GET', credentials: 'same-origin' }) 27 .then(function (r) { return r.ok ? r.json() : null; }) 28 .then(function (d) { if (d && d.nonce) { cfg.nonce = d.nonce; } }) 29 .catch(function () {}); 30 } 31 32 var STORAGE_KEY = 'ppaccess_viewed_content'; 33 34 function getPostIdFromBody() { 35 var body = document.body; 36 if (!body) { return null; } 37 var classes = body.className.split(/\s+/); 38 for (var i = 0; i < classes.length; i++) { 39 var cls = classes[i]; 40 if (cls.indexOf('postid-') === 0) { 41 var pid = parseInt(cls.substring(7), 10); 42 if (pid > 0) { return pid; } 43 } 44 if (cls.indexOf('page-id-') === 0) { 45 var pageId = parseInt(cls.substring(8), 10); 46 if (pageId > 0) { return pageId; } 47 } 48 } 49 return null; 50 } 51 52 function getViewedContent() { 53 try { 54 var raw = localStorage.getItem(STORAGE_KEY); 55 if (raw) { return JSON.parse(raw) || {}; } 56 } catch (e) { /* unavailable / corrupt */ } 57 return {}; 58 } 59 60 function saveViewedContent(content) { 61 try { localStorage.setItem(STORAGE_KEY, JSON.stringify(content || {})); } 62 catch (e) { /* unavailable */ } 63 } 64 65 function cleanExpired(content) { 66 var now = Math.floor(Date.now() / 1000); 67 var changed = false; 68 for (var pt in content) { 69 if (!Object.prototype.hasOwnProperty.call(content, pt)) { continue; } 70 var bucket = content[pt]; 71 for (var pid in bucket) { 72 if (Object.prototype.hasOwnProperty.call(bucket, pid) && bucket[pid] < now) { 73 delete bucket[pid]; 74 changed = true; 75 } 76 } 77 if (Object.keys(bucket).length === 0) { 78 delete content[pt]; 79 changed = true; 80 } 81 } 82 if (changed) { saveViewedContent(content); } 83 return content; 84 } 85 86 function getContainers() { 87 var body = document.body; 88 var selector; 89 if (body && body.className.indexOf('page-id-') !== -1) { 90 selector = cfg.page_container || ''; 91 } else { 92 selector = cfg.post_container || ''; 93 } 94 if (!selector) { return []; } 95 var selectors = selector.split(',').map(function (s) { return s.trim(); }).filter(Boolean); 96 var nodes = []; 97 selectors.forEach(function (sel) { 98 var found = document.querySelectorAll(sel); 99 for (var i = 0; i < found.length; i++) { nodes.push(found[i]); } 100 }); 101 return nodes; 102 } 103 104 // Trim a node's children to a remaining word budget, mirroring the server's 105 // truncate_to_lead_in so the teaser matches and doesn't shift on load. Text 106 // nodes consume the budget; the node it runs out on is cut at a word boundary 107 // and everything after is removed. remaining is a one-element array (by ref). 108 function truncateNodeToWords(node, remaining) { 109 var children = Array.prototype.slice.call(node.childNodes); 110 for (var i = 0; i < children.length; i++) { 111 var child = children[i]; 112 if (remaining[0] <= 0) { node.removeChild(child); continue; } 113 if (child.nodeType === 3) { // text node 114 var words = (child.textContent || '').split(/\s+/).filter(Boolean); 115 if (words.length <= remaining[0]) { 116 remaining[0] -= words.length; 117 } else { 118 child.textContent = words.slice(0, remaining[0]).join(' ') + 'â¦'; 119 remaining[0] = 0; 120 } 121 } else if (child.nodeType === 1) { // element 122 truncateNodeToWords(child, remaining); 123 } 124 } 125 } 126 127 function leadInHtml(container, n) { 128 if (!n || n <= 0) { return ''; } 129 var clone = container.cloneNode(true); 130 truncateNodeToWords(clone, [n]); 131 return clone.innerHTML; 132 } 133
134 function reveal(containers) { 135 containers.forEach(function (c) { c.style.visibility = 'visible'; }); 136 } 137 138 // Assign HTML and re-run any <script> tags it contains. innerHTML never 139 // executes injected scripts, so inline initializers delivered in 140 // full_content (map tokens, data-table setup, embeds) would otherwise stay 141 // dead â leaving loading skeletons and "x is not defined" errors. Cloning 142 // each script into a fresh element makes the browser execute it in order. 143 function setHtmlWithScripts(container, html) { 144 container.innerHTML = html; 145 var scripts = container.querySelectorAll('script'); 146 for (var i = 0; i < scripts.length; i++) { 147 var old = scripts[i]; 148 var s = document.createElement('script'); 149 for (var j = 0; j < old.attributes.length; j++) { 150 s.setAttribute(old.attributes[j].name, old.attributes[j].value); 151 } 152 s.text = old.textContent || ''; 153 old.parentNode.replaceChild(s, old); 154 } 155 } 156 157 function isSlideupPaywall(paywallHtml) { 158 return paywallHtml.indexOf('ppaccess-paywall--slideup') !== -1; 159 } 160 161 function showInlinePaywall(containers, paywallHtml) { 162 var leadCount = parseInt(cfg.lead_in_words, 10); 163 if (isNaN(leadCount)) { leadCount = 75; } 164 var fade = cfg.fade_color || '#ffffff'; 165 containers.forEach(function (c, idx) { 166 if (containers.length > 1 && idx > 0) { 167 c.innerHTML = ''; 168 } else { 169 var leadHtml = leadInHtml(c, leadCount); 170 // Wrap lead-in so a gradient overlay can fade the last paragraph 171 // into the page background. Color is admin-configurable so it 172 // matches the theme â important for sites with off-white or 173 // dark backgrounds. 174 var wrapped = leadHtml 175 ? '<div class="ppaccess-paywall-leadin" style="--ppaccess-fade-color:' + fade + '">' + leadHtml + '</div>' 176 : ''; 177 c.innerHTML = wrapped + paywallHtml; 178 } 179 c.style.visibility = 'visible'; 180 }); 181 } 182 183 function showSlideupPaywall(containers, paywallHtml) { 184 // Bottom-sheet pattern: article stays behind the locked offer. 185 reveal(containers); 186 187 var existing = document.querySelector('.ppaccess-slideup-root'); 188 if (existing && existing.parentNode) { 189 existing.parentNode.removeChild(existing); 190 } 191 192 var root = document.createElement('div'); 193 root.className = 'ppaccess-slideup-root'; 194 root.innerHTML = '<div class="ppaccess-slideup-mask" aria-hidden="true"></div><div class="ppaccess-slideup-portal">' + paywallHtml + '</div>'; 195 document.body.classList.add('ppaccess-slideup-open'); 196 document.documentElement.classList.add('ppaccess-slideup-open'); 197 document.body.appendChild(root); 198 199 // Show the sheet immediately in its starting position. 200 root.classList.add('is-visible'); 201 root.classList.add('is-active'); 202 203 var slideupEl = root.querySelector('.ppaccess-paywall--slideup'); 204 // Resolve the resting top to pixels. The CSS var may be in vh (mobile) 205 // or px (desktop); parseInt('38vh') would wrongly yield 38px and trap 206 // the sheet near the top when sliding back down. The element's computed 207 // `top` is always resolved to px, so prefer that, with a vh-aware 208 // fallback on the raw custom property. 209 var startTop = slideupEl ? parseInt(getComputedStyle(slideupEl).top, 10) : NaN; 210 if (!startTop || isNaN(startTop)) { 211 var rawTop = getComputedStyle(root).getPropertyValue('--ppaccess-slideup-top').trim(); 212 startTop = rawTop.indexOf('vh') !== -1 213 ? Math.round(parseFloat(rawTop) * window.innerHeight / 100) 214 : (parseInt(rawTop, 10) || 350); 215 } 216 var currentTop = startTop; 217 218 // Drive the fade color from the panel's real background so the article 219 // fades into whatever color the panel is â white, a preset (e.g. the 220 // ft-classic cream), or a custom Box background color. 221 if (slideupEl) { 222 var panelBg = getComputedStyle(slideupEl).backgroundColor; 223 if (panelBg && panelBg !== 'rgba(0, 0, 0, 0)' && panelBg !== 'transparent') { 224 root.style.setProperty('--ppaccess-slideup-bg', panelBg); 225 } 226 } 227 228 // Disable CSS transition so gesture-driven movement is instant. 229 if (slideupEl) { slideupEl.style.transition = 'none'; } 230 231 function setTop(t) { 232 currentTop = Math.max(0, Math.min(startTop, t)); 233 if (slideupEl) { slideupEl.style.top = currentTop + 'px'; } 234 } 235 236 // Drive sheet position from wheel events (body scroll is locked). 237 window.addEventListener('wheel', function (e) { 238 setTop(currentTop - e.deltaY); 239 }, { passive: true }); 240 241 // Touch support. 242 var touchStartY = null; 243 window.addEventListener('touchstart', function (e) { 244 touchStartY = e.touches[0].clientY; 245 }, { passive: true }); 246 window.addEventListener('touchmove', function (e) { 247 if (touchStartY === null) { return; } 248 var delta = touchStartY - e.touches[0].clientY; 249 touchStartY = e.touches[0].clientY; 250 setTop(currentTop - delta); 251 }, { passive: true }); 252 } 253 254 function showModalPaywall(containers, paywallHtml) { 255 // Reveal the article behind the modal â modal layout does not hide content.
256 reveal(containers); 257 258 var root = document.createElement('div'); 259 root.className = 'ppaccess-modal-root'; 260 root.innerHTML = paywallHtml; 261 document.body.appendChild(root); 262 263 var modal = root.querySelector('.ppaccess-paywall-modal'); 264 if (!modal) { return; } 265 266 var trigger = modal.getAttribute('data-trigger') || 'load'; 267 var delay = parseInt(modal.getAttribute('data-delay'), 10) || 0; 268 269 function open() { 270 modal.removeAttribute('hidden'); 271 document.body.classList.add('ppaccess-modal-open'); 272 } 273 function close() { 274 modal.setAttribute('hidden', 'hidden'); 275 document.body.classList.remove('ppaccess-modal-open'); 276 } 277 278 // Dismiss via backdrop click. The Escape key is intentionally not wired 279 // since this is a paywall â letting users dismiss with Esc would defeat 280 // the purpose. Backdrop dismiss exists so a user who already subscribed 281 // can re-read without page reload. 282 root.addEventListener('click', function (ev) { 283 if (ev.target && ev.target.hasAttribute('data-ppaccess-modal-close')) { close(); } 284 }); 285 286 if ('load' === trigger) { 287 if (delay > 0) { setTimeout(open, delay * 1000); } 288 else { open(); } 289 return; 290 } 291 292 if ('scroll' === trigger) { 293 var fired = false; 294 var onScroll = function () { 295 if (fired) { return; } 296 var docH = document.documentElement.scrollHeight - window.innerHeight; 297 var ratio = docH > 0 ? (window.scrollY / docH) : 0; 298 if (ratio >= 0.4) { 299 fired = true; 300 window.removeEventListener('scroll', onScroll); 301 open(); 302 } 303 }; 304 window.addEventListener('scroll', onScroll, { passive: true }); 305 return; 306 } 307 308 if ('exit' === trigger) { 309 var fired2 = false; 310 document.addEventListener('mouseleave', function (ev) { 311 if (fired2) { return; } 312 if (ev.clientY <= 0) { 313 fired2 = true; 314 open(); 315 } 316 }); 317 return; 318 } 319 } 320 321 function dispatch(name, detail) { 322 var ev; 323 try { ev = new CustomEvent(name, { detail: detail, bubbles: true, cancelable: true }); } 324 catch (e) { return; } 325 document.dispatchEvent(ev); 326 } 327 328 function getGiftToken() { 329 try { 330 var value = new URLSearchParams(window.location.search).get('ppgift') || ''; 331 return value.replace(/[^A-Za-z0-9]/g, ''); 332 } catch (e) { 333 return ''; 334 } 335 } 336 337 function resolve(postId) { 338 // When IP metering is enabled (cfg.ip_meter), always send empty viewed_content 339 // so the server IP meter is the sole gate â localStorage can be bypassed 340 // by clearing browser data or opening incognito, so we never rely on it. 341 var viewed = cfg.ip_meter ? {} : cleanExpired(getViewedContent()); 342 var headers = { 'Content-Type': 'application/json' }; 343 344 // Match LP's behavior: only send the nonce for logged-in users. 345 // Anonymous visitors hitting a cached page may have a stale nonce that 346 // would 403 the request â omitting it lets the public endpoint succeed. 347 if (cfg.nonce && document.body.classList.contains('logged-in')) { 348 headers['X-WP-Nonce'] = cfg.nonce; 349 } 350 351 var payload = { post_id: postId, viewed_content: viewed }; 352 353 // Gift links carry their token in the query string. Read it from the live 354 // URL rather than from localized config: this page may have been served 355 // from a full-page cache, and a token baked into that HTML would belong 356 // to whoever populated the cache, not to the person reading now. 357 var giftToken = getGiftToken(); 358 if (giftToken) { payload.gift_token = giftToken; } 359 360 fetch(cfg.rest_url, { 361 method: 'POST', 362 headers: headers, 363 credentials: 'same-origin', 364 body: JSON.stringify(payload) 365 }) 366 .then(function (r) { 367 if (!r.ok) { throw new Error('resolve_failed_' + r.status); } 368 return r.json(); 369 }) 370 .then(function (data) { 371 var containers = getContainers(); 372 373 var paywallContent = data ? data.paywall_content : ''; 374 if (data && data.show_paywall && paywallContent) { 375 setNagLocCookie(postId); 376 var layout = (data.layout || cfg.layout || 'inline'); 377 if (!containers.length) { 378 // No matching container - custom post type template. Fall back to 379 // slideup overlay so the paywall still appears. 380 showSlideupPaywall([], paywallContent); 381 } else if (isSlideupPaywall(paywallContent)) { 382 showSlideupPaywall(containers, paywallContent); 383 } else if ('modal' === layout) { 384 showModalPaywall(containers, paywallContent); 385 } else { 386 showInlinePaywall(containers, paywallContent); 387 } 388 dispatch('ppaccess:paywall_shown', { postId: postId, layout: layout, response: data }); 389 return; 390 } 391 392 if (!containers.length) { return; } 393 if (data && data.full_content) {
394 containers.forEach(function (c) { setHtmlWithScripts(c, data.full_content); }); 395 } 396 reveal(containers); 397 clearNagLocCookie(); 398 if (data && data.viewed_content && !cfg.ip_meter) { saveViewedContent(data.viewed_content); } 399 dispatch('ppaccess:access_granted', { postId: postId, response: data }); 400 }) 401 .catch(function () { 402 // On error, reveal so we never silently hide content. 403 reveal(getContainers()); 404 }); 405 } 406 407 function setNagLocCookie(postId) { 408 if (!postId) { return; } 409 var expires = new Date(Date.now() + 30 * 60 * 1000).toUTCString(); 410 document.cookie = 'ppaccess_nag_loc=' + encodeURIComponent(postId) + '; expires=' + expires + '; path=/; SameSite=Lax'; 411 } 412 413 function clearNagLocCookie() { 414 document.cookie = 'ppaccess_nag_loc=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax'; 415 } 416 417 function init() { 418 var postId = cfg.post_id || getPostIdFromBody(); 419 if (!postId) { return; } 420 resolve(postId); 421 } 422 423 if (document.readyState === 'loading') { 424 document.addEventListener('DOMContentLoaded', init); 425 } else { 426 init(); 427 } 428})();
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.