1(() => { 2 'use strict'; 3 4 if (!window.Joomla || typeof window.Joomla.getOptions !== 'function') { 5 return; 6 } 7 8 const options = window.Joomla.getOptions('plg_system_gdprconsent') || {}; 9 const debug = !!options.debug; 10 11 const log = (...args) => { 12 if (debug) { 13 console.log('[GDPRConsent]', ...args); 14 } 15 }; 16 17 const STORAGE = { 18 LOCAL: 'localstorage', 19 COOKIE: 'cookie', 20 }; 21 22 const runtime = { 23 activatedScripts: [], 24 activatedIframes: [], 25 blockedScripts: 0, 26 blockedIframes: 0, 27 }; 28 29 const safeString = (value, fallback = '') => (typeof value === 'string' && value.length ? value : fallback); 30 const clone = (value) => JSON.parse(JSON.stringify(value)); 31 32 const storageKey = safeString(options.storageKey, 'joomla_gdprconsent_v3'); 33 const cookieName = safeString(options.cookieName, 'joomla_gdprconsent_v3'); 34 const cookieDays = Number.isFinite(options.cookieDays) ? options.cookieDays : 180; 35 const cookieSameSite = safeString(options.cookieSameSite, 'Lax'); 36 const cookieSecure = !!options.cookieSecure; 37 38 const text = { 39 title: safeString(options.texts?.title, 'Cookies'), 40 message: safeString(options.texts?.message, ''), 41 settingsTitle: safeString(options.texts?.settingsTitle, 'Cookie settings'), 42 settingsIntro: safeString(options.texts?.settingsIntro, ''), 43 acceptAll: safeString(options.texts?.acceptAll, 'Accept all'), 44 reject: safeString(options.texts?.reject, 'Reject non-essential'), 45 settings: safeString(options.texts?.settings, 'Settings'), 46 save: safeString(options.texts?.save, 'Save preferences'), 47 close: safeString(options.texts?.close, 'Close'), 48 policy: safeString(options.texts?.policy, 'Privacy policy'), 49 manage: safeString(options.texts?.manage, 'Manage cookies'), 50 alwaysEnabled: safeString(options.texts?.alwaysEnabled, 'Always enabled'), 51 emptyState: safeString(options.texts?.emptyState, 'No optional categories are configured.'), 52 blockedEmbedTitle: safeString(options.texts?.blockedEmbedTitle, 'Embedded content is blocked'), 53 blockedEmbedText: safeString(options.texts?.blockedEmbedText, 'Allow the required cookie category to load this content.'), 54 blockedEmbedButton: safeString(options.texts?.blockedEmbedButton, 'Open settings'), 55 }; 56 57 const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({ 58 '&': '&', 59 '<': '<', 60 '>': '>', 61 '"': '"', 62 "'": ''', 63 }[char])); 64 65 const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 66 67 const readCookie = (name) => { 68 const match = document.cookie.match(new RegExp(`(^|;\\s*)${escapeRegExp(name)}=([^;]*)`)); 69 return match ? decodeURIComponent(match[2]) : null; 70 }; 71 72 const writeCookie = (name, value, days) => { 73 const expires = new Date(Date.now() + days * 864e5).toUTCString(); 74 const parts = [ 75 `${encodeURIComponent(name)}=${encodeURIComponent(value)}`, 76 `expires=${expires}`, 77 'path=/', 78 `SameSite=${cookieSameSite}`, 79 ]; 80 81 if (cookieSecure || cookieSameSite === 'None') { 82 parts.push('Secure'); 83 } 84 85 document.cookie = parts.join('; '); 86 }; 87 88 const removeCookie = (name) => { 89 const parts = [ 90 `${encodeURIComponent(name)}=`, 91 'expires=Thu, 01 Jan 1970 00:00:00 GMT', 92 'path=/', 93 `SameSite=${cookieSameSite}`, 94 ]; 95 96 if (cookieSecure || cookieSameSite === 'None') { 97 parts.push('Secure'); 98 } 99 100 document.cookie = parts.join('; '); 101 }; 102 103 const defaultState = () => { 104 const categories = {}; 105 const defs = options.categories || {}; 106 107 Object.keys(defs).forEach((key) => { 108 const def = defs[key] || {}; 109 if (def.enabled === false) return; 110 categories[key] = !!def.default; 111 if (def.required) { 112 categories[key] = true; 113 } 114 }); 115 116 return { 117 version: options.stateVersion || 31, 118 decided: false, 119 categories, 120 updatedAt: null, 121 }; 122 }; 123 124 const normalizeState = (inputState) => { 125 const defs = options.categories || {}; 126 const output = inputState && typeof inputState === 'object' ? clone(inputState) : defaultState(); 127 128 output.version = options.stateVersion || 31; 129 output.decided = !!output.decided; 130 output.categories = output.categories && typeof output.categories === 'object' ? output.categories : {}; 131 output.updatedAt = output.updatedAt || null; 132
133 Object.keys(defs).forEach((key) => { 134 const def = defs[key] || {}; 135 136 if (def.enabled === false) { 137 delete output.categories[key]; 138 return; 139 } 140 141 if (!(key in output.categories)) { 142 output.categories[key] = !!def.default; 143 } 144 145 if (def.required) { 146 output.categories[key] = true; 147 } 148 }); 149 150 return output; 151 }; 152 153 const loadState = () => { 154 try { 155 if (options.storage === STORAGE.COOKIE) { 156 const raw = readCookie(cookieName); 157 return raw ? normalizeState(JSON.parse(raw)) : null; 158 } 159 160 const raw = window.localStorage.getItem(storageKey); 161 return raw ? normalizeState(JSON.parse(raw)) : null; 162 } catch (error) { 163 log('Failed to load consent state', error); 164 return null; 165 } 166 }; 167 168 const saveState = (state) => { 169 const raw = JSON.stringify(state); 170 171 try { 172 if (options.storage === STORAGE.COOKIE) { 173 writeCookie(cookieName, raw, cookieDays); 174 return; 175 } 176 177 window.localStorage.setItem(storageKey, raw); 178 } catch (error) { 179 log('Failed to save consent state', error); 180 } 181 }; 182 183 const clearState = () => { 184 try { 185 if (options.storage === STORAGE.COOKIE) { 186 removeCookie(cookieName); 187 } else { 188 window.localStorage.removeItem(storageKey); 189 } 190 } catch (error) { 191 log('Failed to clear consent state', error); 192 } 193 }; 194 195 let state = normalizeState(loadState() || defaultState()); 196 197 const hasOptionalCategory = () => { 198 const defs = options.categories || {}; 199 return Object.keys(defs).some((key) => { 200 const def = defs[key] || {}; 201 return def.enabled !== false && !def.required && key !== 'essential'; 202 }); 203 }; 204 205 const isAllowed = (categoryKey) => !!(state.categories && state.categories[categoryKey]); 206 207 const setAll = (value) => { 208 const defs = options.categories || {}; 209 Object.keys(defs).forEach((key) => { 210 const def = defs[key] || {}; 211 if (def.enabled === false) return; 212 if (def.required) { 213 state.categories[key] = true; 214 return; 215 } 216 state.categories[key] = !!value; 217 }); 218 }; 219 220 const pushConsentEvent = () => { 221 window.dataLayer = window.dataLayer || []; 222 window.dataLayer.push({ 223 event: 'joomla_gdpr_consent_update', 224 joomla_gdpr_consent: { 225 decided: state.decided, 226 categories: clone(state.categories), 227 updatedAt: state.updatedAt, 228 }, 229 }); 230 }; 231 232 const mapGoogleConsent = () => { 233 const preferencesEnabled = options.categories?.preferences?.enabled !== false; 234 const preferencesAllowed = preferencesEnabled ? !!state.categories.preferences : true; 235 236 return { 237 ad_storage: isAllowed('marketing') ? 'granted' : 'denied', 238 ad_user_data: isAllowed('marketing') ? 'granted' : 'denied', 239 ad_personalization: isAllowed('marketing') ? 'granted' : 'denied', 240 analytics_storage: isAllowed('analytics') ? 'granted' : 'denied', 241 functionality_storage: preferencesAllowed ? 'granted' : 'denied', 242 personalization_storage: preferencesEnabled && preferencesAllowed ? 'granted' : 'denied', 243 security_storage: 'granted', 244 }; 245 }; 246 247 const syncGoogleConsent = () => { 248 if (!options.consentMode?.enabled) { 249 return; 250 } 251 252 window.dataLayer = window.dataLayer || []; 253 window.gtag = window.gtag || function gtag(){ window.dataLayer.push(arguments); }; 254 window.gtag('consent', 'update', mapGoogleConsent()); 255 }; 256 257 const dispatchChange = () => { 258 document.dispatchEvent(new CustomEvent('joomla-gdprconsent:changed', { 259 detail: clone(state), 260 })); 261 }; 262 263 const recordActivation = (listName, vendor, category) => { 264 const list = runtime[listName]; 265 if (!Array.isArray(list)) return; 266 const key = `${vendor}|${category}`; 267 if (!list.find((item) => item.key === key)) { 268 list.push({ key, vendor, category, at: new Date().toISOString() }); 269 } 270 }; 271 272 const copyScriptAttributes = (source, target) => { 273 Array.from(source.attributes).forEach((attribute) => { 274 if (attribute.name === 'type') return; 275 if (attribute.name.startsWith('data-gdpr-')) return; 276 target.setAttribute(attribute.name, attribute.value); 277 }); 278 279 if (source.dataset.gdprType) { 280 target.type = source.dataset.gdprType; 281 } 282 }; 283 284 const activateDeferredScripts = () => { 285 if (!options.autoActivateScripts) { 286 return; 287 } 288 289 const deferredScripts = document.querySelectorAll('script[type="text/plain"][data-gdpr-category]'); 290 runtime.blockedScripts = deferredScripts.length; 291
292 deferredScripts.forEach((script) => { 293 const category = script.dataset.gdprCategory; 294 const vendor = script.dataset.gdprVendor || 'custom'; 295 296 if (!category || !isAllowed(category)) { 297 return; 298 } 299 300 const replacement = document.createElement('script'); 301 copyScriptAttributes(script, replacement); 302 303 if (script.getAttribute('src')) { 304 replacement.src = script.getAttribute('src'); 305 } 306 307 if (!script.getAttribute('src') && script.textContent) { 308 replacement.textContent = script.textContent; 309 } 310 311 script.parentNode.insertBefore(replacement, script); 312 script.remove(); 313 recordActivation('activatedScripts', vendor, category); 314 }); 315 }; 316 317 const removeEmbedPlaceholder = (iframe) => { 318 const placeholderId = iframe.dataset.gdprPlaceholderId; 319 if (!placeholderId) return; 320 const placeholder = document.getElementById(placeholderId); 321 if (placeholder) { 322 placeholder.remove(); 323 } 324 delete iframe.dataset.gdprPlaceholderId; 325 }; 326 327 const createEmbedPlaceholder = (iframe, category) => { 328 const placeholder = document.createElement('div'); 329 const placeholderId = `joomla-gdprconsent-placeholder-${Math.random().toString(36).slice(2)}`; 330 331 placeholder.id = placeholderId; 332 placeholder.className = 'joomla-gdprconsent-embed-placeholder'; 333 placeholder.innerHTML = ` 334 <div class="joomla-gdprconsent-embed-placeholder__title">${escapeHtml(text.blockedEmbedTitle)}</div> 335 <div class="joomla-gdprconsent-embed-placeholder__text">${escapeHtml(text.blockedEmbedText)}</div> 336 <div class="joomla-gdprconsent-embed-placeholder__meta">${escapeHtml(category)}</div> 337 <button type="button" class="joomla-gdprconsent-embed-placeholder__button">${escapeHtml(text.blockedEmbedButton)}</button> 338 `; 339 340 placeholder.querySelector('button')?.addEventListener('click', () => { 341 renderModal(); 342 }); 343 344 iframe.insertAdjacentElement('beforebegin', placeholder); 345 iframe.dataset.gdprPlaceholderId = placeholderId; 346 }; 347 348 const updateDeferredEmbeds = () => { 349 const deferredIframes = document.querySelectorAll('iframe[data-gdpr-src][data-gdpr-category]'); 350 runtime.blockedIframes = Array.from(deferredIframes).filter((iframe) => !isAllowed(iframe.dataset.gdprCategory || '')).length; 351 352 deferredIframes.forEach((iframe) => { 353 const category = iframe.dataset.gdprCategory; 354 const vendor = iframe.dataset.gdprVendor || 'embed'; 355 356 if (!category) return; 357 358 if (isAllowed(category)) { 359 if (!iframe.getAttribute('src')) { 360 iframe.setAttribute('src', iframe.dataset.gdprSrc || ''); 361 recordActivation('activatedIframes', vendor, category); 362 } 363 iframe.style.display = ''; 364 removeEmbedPlaceholder(iframe); 365 return; 366 } 367 368 iframe.style.display = 'none'; 369 if (!iframe.dataset.gdprPlaceholderId) { 370 createEmbedPlaceholder(iframe, category); 371 } 372 }); 373 }; 374 375 const shouldReloadForRevocation = (previousState, nextState) => { 376 if (!options.reloadOnRevocation || !previousState) { 377 return false; 378 } 379 380 const prevCategories = previousState.categories || {}; 381 const nextCategories = nextState.categories || {}; 382 383 return Object.keys(nextCategories).some((key) => !!prevCategories[key] && !nextCategories[key]); 384 }; 385 386 const closeBanner = () => { 387 document.getElementById('joomla-gdprconsent-banner')?.remove(); 388 }; 389 390 const closeModal = () => { 391 document.getElementById('joomla-gdprconsent-modal')?.remove(); 392 }; 393 394 const renderManageButton = () => { 395 if (!options.showManageButton) { 396 document.getElementById('joomla-gdprconsent-manage')?.remove(); 397 return; 398 } 399 400 let button = document.getElementById('joomla-gdprconsent-manage'); 401 402 if (!button) { 403 button = document.createElement('button'); 404 button.type = 'button'; 405 button.id = 'joomla-gdprconsent-manage'; 406 button.className = `joomla-gdprconsent-manage pos-${safeString(options.manageButtonPosition, 'bottom-left')}`; 407 button.addEventListener('click', () => renderModal()); 408 document.body.appendChild(button); 409 } 410 411 button.className = `joomla-gdprconsent-manage pos-${safeString(options.manageButtonPosition, 'bottom-left')}`; 412 button.textContent = text.manage; 413 }; 414 415 const renderDebugPanel = () => { 416 if (!options.showDebugPanel) { 417 document.getElementById('joomla-gdprconsent-debug')?.remove(); 418 return; 419 } 420 421 let panel = document.getElementById('joomla-gdprconsent-debug'); 422 423 if (!panel) { 424 panel = document.createElement('details'); 425 panel.id = 'joomla-gdprconsent-debug'; 426 panel.className = 'joomla-gdprconsent-debug'; 427 panel.innerHTML = '<summary>GDPR debug</summary><pre></pre>'; 428 document.body.appendChild(panel); 429 } 430 431 const pre = panel.querySelector('pre'); 432 if (pre) { 433 pre.textContent = JSON.stringify({ 434 geo: options.geo || {}, 435 consentMode: options.consentMode || {}, 436 state, 437 blockedScripts: runtime.blockedScripts, 438 blockedIframes: runtime.blockedIframes, 439 activatedScripts: runtime.activatedScripts, 440 activatedIframes: runtime.activatedIframes, 441 configuredVendors: options.configuredVendors || [], 442 }, null, 2); 443 } 444 }; 445 446 const applyState = () => { 447 syncGoogleConsent(); 448 activateDeferredScripts(); 449 updateDeferredEmbeds(); 450 pushConsentEvent(); 451 dispatchChange(); 452 renderManageButton(); 453 renderDebugPanel(); 454 }; 455 456 const persistAndApply = (decided, previousState) => { 457 state.decided = !!decided; 458 state.updatedAt = new Date().toISOString(); 459 saveState(state); 460 applyState(); 461 462 if (shouldReloadForRevocation(previousState, state)) { 463 window.setTimeout(() => window.location.reload(), 80); 464 } 465 }; 466 467 const renderModal = () => { 468 closeModal(); 469 470 const defs = options.categories || {}; 471 const rows = Object.keys(defs) 472 .filter((key) => defs[key]?.enabled !== false) 473 .map((key) => { 474 const def = defs[key] || {}; 475 const checked = isAllowed(key) ? 'checked' : ''; 476 const disabled = def.required ? 'disabled' : ''; 477 const hint = def.required ? `<span class="joomla-gdprconsent-toggle__hint">${escapeHtml(text.alwaysEnabled)}</span>` : ''; 478 479 return ` 480 <div class="joomla-gdprconsent-toggle"> 481 <label class="joomla-gdprconsent-toggle__row"> 482 <input type="checkbox" data-category="${escapeHtml(key
482)}" ${checked} ${disabled}> 483 <span class="joomla-gdprconsent-toggle__label">${escapeHtml(def.label || key)}</span> 484 </label> 485 <div class="joomla-gdprconsent-toggle__desc">${escapeHtml(def.description || '')} ${hint}</div> 486 </div> 487 `; 488 }) 489 .join(''); 490 491 const policyLink = options.privacyPolicyUrl 492 ? `<div class="joomla-gdprconsent-modal__policy"><a href="${escapeHtml(options.privacyPolicyUrl)}">${escapeHtml(text.policy)}</a></div>` 493 : ''; 494 495 const modal = document.createElement('div'); 496 modal.id = 'joomla-gdprconsent-modal'; 497 modal.className = 'joomla-gdprconsent-modal'; 498 modal.setAttribute('role', 'dialog'); 499 modal.setAttribute('aria-modal', 'true'); 500 501 modal.innerHTML = ` 502 <div class="joomla-gdprconsent-modal__backdrop" data-action="close"></div> 503 <div class="joomla-gdprconsent-modal__dialog"> 504 <div class="joomla-gdprconsent-modal__header"> 505 <div class="joomla-gdprconsent-modal__title">${escapeHtml(text.settingsTitle)}</div> 506 <button type="button" class="joomla-gdprconsent-modal__close" data-action="close" aria-label="${escapeHtml(text.close)}">x</button> 507 </div> 508 <div class="joomla-gdprconsent-modal__body"> 509 ${text.settingsIntro ? `<div class="joomla-gdprconsent-modal__intro">${escapeHtml(text.settingsIntro)}</div>` : ''} 510 ${rows || `<div class="joomla-gdprconsent-modal__empty">${escapeHtml(text.emptyState)}</div>`} 511 ${policyLink} 512 </div> 513 <div class="joomla-gdprconsent-modal__footer"> 514 <button type="button" class="btn btn-outline-secondary" data-action="reject">${escapeHtml(text.reject)}</button> 515 <button type="button" class="btn btn-primary" data-action="acceptAll">${escapeHtml(text.acceptAll)}</button> 516 <button type="button" class="btn btn-success" data-action="save">${escapeHtml(text.save)}</button> 517 </div> 518 </div> 519 `; 520 521 document.body.appendChild(modal); 522 523 const destroyModal = () => { 524 closeModal(); 525 document.removeEventListener('keydown', onKeyDown); 526 }; 527 528 const onKeyDown = (event) => { 529 if (event.key === 'Escape') { 530 destroyModal(); 531 } 532 }; 533 534 document.addEventListener('keydown', onKeyDown); 535 536 modal.addEventListener('click', (event) => { 537 const actionElement = event.target.closest('[data-action]'); 538 if (!actionElement) return; 539 540 const action = actionElement.dataset.action; 541 const previousState = clone(state); 542 543 if (action === 'close') { 544 destroyModal(); 545 return; 546 } 547 548 if (action === 'acceptAll') { 549 setAll(true); 550 persistAndApply(true, previousState); 551 destroyModal(); 552 closeBanner(); 553 return; 554 } 555 556 if (action === 'reject') { 557 setAll(false); 558 persistAndApply(true, previousState); 559 destroyModal(); 560 closeBanner(); 561 return; 562 } 563 564 if (action === 'save') { 565 modal.querySelectorAll('input[type="checkbox"][data-category]').forEach((input) => { 566 const key = input.dataset.category; 567 const def = defs[key] || {}; 568 if (def.required) { 569 state.categories[key] = true; 570 return; 571 } 572 state.categories[key] = input.checked; 573 }); 574 persistAndApply(true, previousState); 575 destroyModal(); 576 closeBanner(); 577 } 578 }); 579 }; 580 581 const renderBanner = () => { 582 closeBanner(); 583 584 if (!options.showBanner || !hasOptionalCategory() || state.decided) { 585 return; 586 } 587 588 const policyLink = options.privacyPolicyUrl 589 ? `<div class="joomla-gdprconsent-banner__policy"><a href="${escapeHtml(options.privacyPolicyUrl)}">${escapeHtml(text.policy)}</a></div>` 590 : ''; 591 592 const banner = document.createElement('div'); 593 banner.id = 'joomla-gdprconsent-banner'; 594 banner.className = `joomla-gdprconsent-banner pos-${safeString(options.position, 'bottom')} theme-${safeString(options.theme, 'light')}`; 595 banner.setAttribute('role', 'dialog'); 596 banner.setAttribute('aria-live', 'polite'); 597 598 banner.innerHTML = ` 599 <div class="joomla-gdprconsent-banner__inner"> 600 <div class="joomla-gdprconsent-banner__content"> 601 <div class="joomla-gdprconsent-banner__title">${escapeHtml(text.title)}</div> 602 <div class="joomla-gdprconsent-banner__message">${escapeHtml(text.message)}</div> 603 ${policyLink} 604 </div> 605 <div class="joomla-gdprconsent-banner__actions"> 606 <button type="button" class="btn btn-primary" data-action="acceptAll">${escapeHtml(text.acceptAll)}</button> 607 <button type="button" class="btn btn-outline-secondary" data-action="reject">${escapeHtml(text.reject)}</button> 608 <button type="button" class="btn btn-link" data-action="settings">${escapeHtml(text.settings)}</button> 609 </div> 610 </div> 611 `; 612 613 document.body.appendChild(banner); 614 615 banner.addEventListener('click', (event) => { 616 const button = event.target.closest('button[data-action]'); 617 if (!button) return; 618 619 const action = button.dataset.action; 620 const previousState = clone(state); 621 622 if (action === 'acceptAll') { 623 setAll(true); 624 persistAndApply(true, previousState); 625 closeBanner(); 626 return; 627 } 628 629 if (action === 'reject') { 630 setAll(false); 631 persistAndApply(true, previousState); 632 closeBanner(); 633 return; 634 } 635 636 if (action === 'settings') { 637 renderModal(); 638 } 639 }); 640 }; 641 642 const applyGeoBehavior = () => { 643 if (state.decided) { 644 return; 645 } 646 647 const geo = options.geo || {}; 648 649 if (geo.mode !== 'regulated') { 650 return; 651 } 652 653 const previousState = clone(state); 654 655 if (geo.regulated === false) { 656 if (geo.nonRegulatedBehavior === 'hide_banner_accept_all') { 657 setAll(true); 658 persistAndApply(true, previousState); 659 } else if (geo.nonRegulatedBehavior === 'hide_banner_use_defaults') { 660 state.decided = true; 661 state.updatedAt = new Date().toISOString(); 662 saveState(state); 663 } 664 return; 665 } 666 667 if (geo.regulated === null && geo.unknownBehavior === 'hide_banner_use_defaults') { 668 state.decided = true; 669 state.updatedAt = new Date().toISOString(); 670 saveState(state); 671 } 672 }; 673 674 window.JoomlaGdprConsent = Object.freeze({ 675 getOptions: () => clone(options), 676 getState: () => clone(state), 677 isAllowed: (categoryKey) => isAllowed(categoryKey), 678 acceptAll: () => { 679 const previousState = clone(state); 680 setAll(true); 681 persistAndApply(true, previousState); 682 closeBanner(); 683 closeModal(); 684 }, 685 rejectAll: () => { 686 const previousState = clone(state); 687 setAll(false); 688 persistAndApply(true, previousState); 689 closeBanner(); 690 closeModal(); 691 }, 692 openSettings: () => renderModal(), 693 reset: () => { 694 clearState(); 695 state = normalizeState(defaultState()); 696 renderBanner(); 697 renderManageButton(); 698 renderDebugPanel(); 699 }, 700 onChange: (handler) => { 701 if (typeof handler !== 'function') return () => {}; 702 const listener = (event) => handler(event.detail); 703 document.addEventListener('joomla-gdprconsent:changed', listener); 704 return () => document.removeEventListener('joomla-gdprconsent:changed', listener); 705 }, 706 }); 707 708 document.addEventListener('DOMContentLoaded', () => { 709 applyGeoBehavior(); 710 applyState(); 711 renderBanner(); 712 renderManageButton(); 713 renderDebugPanel(); 714 }); 715})();
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.