1/** 2 * main.js â Site-wide vanilla JS utilities. 3 * - Theme toggle (light / dark / jungle), persisted in localStorage. 4 * - Near-me geolocation redirect helper. 5 * - Tiptap editor stubs (window.initTiptapEditors / window.flushTiptapEditors) 6 * used by admin forms to wire up rich-text editors. 7 */ 8 9// --------------------------------------------------------------------------- 10// Theme management 11// --------------------------------------------------------------------------- 12(function () { 13 const THEMES = ['light', 'dark', 'jungle']; 14 const STORAGE_KEY = 'theme'; 15 const HTML = document.documentElement; 16 17 function applyTheme(theme) { 18 HTML.dataset.theme = THEMES.includes(theme) ? theme : 'light'; 19 } 20 21 // Apply persisted or OS-preferred theme immediately (before paint) 22 const stored = localStorage.getItem(STORAGE_KEY); 23 if (stored && THEMES.includes(stored)) { 24 applyTheme(stored); 25 } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { 26 applyTheme('dark'); 27 } else { 28 applyTheme('light'); 29 } 30 31 // Wire up theme-toggle buttons (data-theme-value="dark" etc.) 32 document.addEventListener('DOMContentLoaded', function () { 33 document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) { 34 btn.addEventListener('click', function () { 35 const next = btn.dataset.themeToggle; 36 if (next && THEMES.includes(next)) { 37 applyTheme(next); 38 try { localStorage.setItem(STORAGE_KEY, next); } catch (e) {} 39 } else { 40 // Cycle through themes 41 const current = HTML.dataset.theme || 'light'; 42 const idx = THEMES.indexOf(current); 43 const newTheme = THEMES[(idx + 1) % THEMES.length]; 44 applyTheme(newTheme); 45 try { localStorage.setItem(STORAGE_KEY, newTheme); } catch (e) {} 46 } 47 }); 48 }); 49 50 // Sync theme indicator elements 51 document.querySelectorAll('[data-theme-indicator]').forEach(function (el) { 52 el.textContent = HTML.dataset.theme || 'light'; 53 }); 54 }); 55})(); 56 57// --------------------------------------------------------------------------- 58// Near-me geolocation helper 59// --------------------------------------------------------------------------- 60window.findNearMe = function (baseUrl) { 61 if (!navigator.geolocation) { 62 alert('Geolocation is not supported by your browser.'); 63 return; 64 } 65 const btn = document.getElementById('near-me-btn'); 66 if (btn) { btn.disabled = true; btn.textContent = 'Locating...'; } 67 68 navigator.geolocation.getCurrentPosition( 69 function (pos) { 70 const { latitude, longitude } = pos.coords; 71 const url = (baseUrl || '/search/') + latitude + '/' + longitude; 72 window.location.href = url; 73 }, 74 function (err) { 75 if (btn) { btn.disabled = false; btn.textContent = 'Near me'; } 76 if (err.code === err.PERMISSION_DENIED) { 77 alert('Location permission denied. Please enable location access and try again.'); 78 } else { 79 alert('Unable to determine your location. Please try searching by city or zip code.'); 80 } 81 }, 82 { timeout: 10000, maximumAge: 60000 } 83 ); 84}; 85 86// --------------------------------------------------------------------------- 87// Jodit RTE integration (admin forms) 88// --------------------------------------------------------------------------- 89(function () { 90 const editors = new Map(); // key â jodit instance 91 92 window.initRteEditors = function () { 93 if (typeof Jodit === 'undefined') return; 94 95 // Patch defaultOptions ONCE before any instance â popup arrays are deep-merged 96 // so per-instance config only appends; we must override the defaults directly. 97 if (!window._rteDefaultsPatched) { 98 window._rteDefaultsPatched = true; 99 Jodit.defaultOptions.popup.selection = [ 100 'paragraph', 'bold', 'italic', 'underline', 'ul', 'ol', 'link', 'eraser', 101 ]; 102 // Drop "vertical align" from the click-an-image popup (delete/pencil/valign/align) 103 // â not useful for these listing descriptions, just leave delete/pencil/align. 104 // Add a "full width" action: Jodit's drag-resize keeps images locked to their 105 // aspect ratio by default, which caps the drag a hair short of the editor's 106 // actual width and never reaches Jodit's own built-in snap-to-100% â so the 107 // image always stops just short of full width however far you drag. This is 108 // a reliable one-click alternative to fighting that with precise dragging. 109 Jodit.defaultOptions.controls.fullwidth = { 110 icon: 'fullsize', 111 tooltip: 'Full width', 112 exec: function (editor, image) { 113 if (!image || image.tagName !== 'IMG') return; 114 image.style.width = '100%'; 115 image.style.height = ''; 116 image.removeAttribute('width'); 117 image.removeAttribute('height'); 118 editor.synchronizeValues(); 119 editor.e.fire('recalcPositionPopup'); 120 }, 121 }; 122 Jodit.defaultOptions.popup.img = Jodit.defaultOptions.popup.img 123 .filter(function (item) { return !item || item.name !== 'valign'; }) 124 .concat(['fullwidth']); 125 Jodit.defaultOptions.controls.paragraph.list = { p: 'Normal', h2: 'Heading' }; 126 // Jodit binds cmd+backspace to its own forward-delete command (apparently meant 127 // as a Mac stand-in for the Windows-only Delete key) and its hotkeys plugin calls 128 // preventDefault on any matched combo â that blocks the browser's native macOS 129 // editing command bound to cmd+backspace (delete to beginning of line) and 130 // replaces it with forward-delete instead. Drop the binding so the keydown falls 131 // through to the OS default. 132 Jodit.defaultOptions.delete.hotkeys.delete = Jodit.defaultOptions.delete.hotkeys.delete 133 .filter(function (hk) { return hk !== 'cmd+backspace'; }); 134 // The image-properties dialog's "Alternative" field is the <img alt> attribute â 135 // Jodit's own i18n string, not worded clearly as such. Override the translation 136 // (Jodit's public API for this â Jodit.lang[language][key]) rather than
136its 137 // default English text, which is just the bare word "Alternative". 138 Jodit.lang.en.Alternative = 'Alt text (for screen readers & SEO)'; 139 // Jodit's built-in "video" control already does what we want for embeds: 140 // its Link tab auto-converts a YouTube/Vimeo URL to an <iframe>, and its 141 // Code tab takes arbitrary embed HTML (e.g. an Instagram/TikTok iframe 142 // snippet) verbatim. Relabel it since "youtube/vimeo" undersells the 143 // Code tab, which is how every other provider gets in. 144 Jodit.defaultOptions.controls.video.tooltip = 'Insert embed (YouTube/Vimeo URL, or paste any iframe embed code)'; 145 } 146 147 // Plain-text length of an editor's value, announced to whatever is listening. 148 // Measured in one place so the form advisory and the editor can't disagree 149 // about how long a description is. Advisory only: nothing blocks or trims. 150 function emitLength(el, key, html) { 151 el.dispatchEvent(new CustomEvent('rte:length', { 152 bubbles: true, 153 detail: { 154 key: key, 155 length: String(html || '').replace(/<[^>]*>/g, ' ').replace(/ /gi, ' ').replace(/\s+/g, ' ').trim().length, 156 }, 157 })); 158 } 159 160 document.querySelectorAll('[data-rte]').forEach(function (el) { 161 const key = el.dataset.rte; 162 if (editors.has(key)) return; 163 164 // Images (insert button + the always-visible "+" trigger) are deliberately 165 // limited to descriptionLong â `description` doubles as the meta description 166 // and excerpt elsewhere, so it needs to stay plain, flattenable text. 167 const allowImages = key === 'descriptionLong'; 168 // Embeds (YouTube/Vimeo via the video control's Link tab, or any other 169 // provider â Instagram, TikTok, etc. â by pasting their iframe embed 170 // code into its Code tab) are opt-in per textarea via data-rte-embed, 171 // set only on the admin-only map single edit page for now. Deliberately 172 // narrower than allowImages: the same descriptionLong field is also 173 // rendered on the public, unauthenticated /map/:slug/suggest form, and 174 // an arbitrary iframe from an anonymous visitor is a bigger review 175 // burden than a pasted image. 176 const allowEmbeds = el.dataset.rteEmbed === '1'; 177 178 const options = { 179 theme: 'default', 180 toolbar: false, 181 toolbarInlineForSelection: true, 182 showXPathInStatusbar: false, 183 showCharsCounter: false, 184 showWordsCounter: false, 185 tabIndex: 0, 186 defaultActionOnPaste: 'insert_clear_html', 187 askBeforePasteHTML: false, 188 askBeforePasteFromWord: false, 189 disablePlugins: [ 190 'speech-recognize', 'about', 'drag-and-drop-element', 191 'file', 'media', 'video', 'print', 'fullsize', 192 'preview', 'color', 'copyformat', 'classSpan', 'table', 193 'indent', 'font', 'hr', 'iframe', 'align', 'lineHeight', 'add-new-line', 194 ].filter(function (name) { return !allowEmbeds || name !== 'video'; }).join(','), 195 controls: { 196 ul: { hasTrigger: false }, 197 ol: { hasTrigger: false }, 198 }, 199 cleanHTML: { 200 removeOnError: true, 201 removeEventAttributes: true, 202 // Deny <img> outright (not just hide the toolbar button) everywhere but 203 // descriptionLong, so a pasted <img> can't sneak past the missing button. 204 // <script> stays denied even where embeds are allowed â that's what 205 // keeps this to "third-party iframe" (browser-sandboxed by origin) 206 // rather than "third-party JS running on our own page." 207 denyTags: ['script', 'iframe', 'object', 'embed', 'style', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'figure', 'figcaption'] 208 .filter(function (tag) { return !allowEmbeds || tag !== 'iframe'; }) 209 .concat(allowImages ? [] : ['img']) 210 .join(','), 211 // Jodit sandboxes every <iframe> by default (sandbox="", which blocks 212 // scripts/same-origin) â fine for an arbitrary pasted iframe, but it 213 // breaks the YouTube/Instagram/TikTok players we're deliberately 214 // letting an admin insert here. 215 sandboxIframesInContent: !allowEmbeds, 216 allowedStyles: Object.assign({ 217 '*': ['font-weight', 'font-style', 'text-decoration', 'text-decoration-line'], 218 // img: resizer drag-handles and the align popup button both write these 219 // directly onto the element's style attribute â without an allowance here, 220 // cleanHTML strips them on the very next keystroke/cl
220ick. 221 img: ['width', 'height', 'float', 'display', 'margin-left', 'margin-right'], 222 }, allowEmbeds ? { 223 // iframe: we normalize every embed down to an aspect-ratio (see the 224 // 'change' handler below) instead of a fixed pixel width/height â 225 // without an allowance here cleanHTML strips that style right back off. 226 iframe: ['aspect-ratio'], 227 } : {}), 228 }, 229 // Strip the "Advanced" tab out of the image-properties dialog (margins/align/ 230 // style/class/id/border-radius) â quick align is already on the image's own 231 // click popup, and the rest is more control than this editor needs. 232 image: { 233 editMargins: false, 234 editAlign: false, 235 editStyle: false, 236 editClass: false, 237 editId: false, 238 editBorderRadius: false, 239 }, 240 height: 'auto', 241 minHeight: 120, 242 }; 243 244 if (allowImages) { 245 const popupItems = ['paragraph', 'bold', 'italic', 'underline', 'ul', 'ol', 'link', 'image', 'eraser'] 246 .concat(allowEmbeds ? ['video'] : []); 247 options.popup = { 248 selection: popupItems, 249 insert: popupItems, 250 }; 251 } 252 253 const instance = Jodit.make(el, options); 254 255 // Seed the length advisory with whatever the field already holds. Without 256 // this the only other place that knows how to measure is the change handler 257 // below, so an editor opened on an existing long description would look fine 258 // until the first keystroke. 259 emitLength(el, key, instance.value); 260 261 // Normalize empty HTML (e.g. <p><br></p>) â '' so forms never store blank markup. 262 // Stripping tags to test for "empty" also strips a lone <img> with no caption 263 // text â without the media check, inserting an image into a blank editor 264 // triggers this same 'change' event and immediately wipes it back out. 265 instance.events.on('change', function (newValue) { 266 const text = newValue.replace(/<[^>]*>/g, '').replace(/ /gi, '').trim(); 267 const hasMedia = /<(img|iframe)\b/i.test(newValue); 268 if (!text && !hasMedia && newValue !== '') { 269 instance.value = ''; 270 } 271 // Jodit edits happen in its own contenteditable, so nothing watching the 272 // original textarea ever hears about them. Re-emit as a real input event 273 // so an RTE field is observable like any other form control. 274 el.dispatchEvent(new Event('input', { bubbles: true })); 275 emitLength(el, key, newValue); 276 }); 277 278 if (allowEmbeds) { 279 // Instagram and TikTok's official "copy embed code" hands you a 280 // <blockquote> + <script> pair. The <script> (which we deliberately 281 // still strip) is what upgrades the inert blockquote into the real 282 // embed, so pasted as-is it renders as a dead skeleton. Both providers 283 // also expose a plain, script-free <iframe> endpoint keyed by the
284 // post's own id â so intercept the paste, pull that id straight out of 285 // the markup (or a bare post URL), and insert the clean iframe instead. 286 // Only the id is taken from the paste; the host and path are hardcoded, 287 // so pasted markup can't point the iframe anywhere arbitrary. 288 var embedFromPaste = function (raw) { 289 if (!raw) return null; 290 var ig = raw.match(/instagram\.com\/(p|reel|tv)\/([A-Za-z0-9_-]+)/i); 291 if (ig) return { src: 'https://www.instagram.com/' + ig[1] + '/' + ig[2] + '/embed/captioned/', width: 400, height: 480 }; 292 var tt = (raw.match(/data-video-id=["'](\d+)["']/i) || raw.match(/tiktok\.com\/[^"'\s]*\/video\/(\d+)/i)); 293 if (tt && /tiktok/i.test(raw)) return { src: 'https://www.tiktok.com/embed/v2/' + tt[1], width: 325, height: 750 }; 294 return null; 295 }; 296 instance.events.on('beforePaste', function (e) { 297 var dt = e.clipboardData || (e.originalEvent && e.originalEvent.clipboardData); 298 if (!dt) return; 299 // Instagram/TikTok "copy embed code" lands as either text/html (a copied 300 // rendered node) or text/plain (the raw code from a code box), so check both. 301 var embed = embedFromPaste(dt.getData('text/html')) || embedFromPaste(dt.getData('text/plain')); 302 if (!embed) return; // not a recognized embed â let normal paste proceed 303 instance.s.insertHTML( 304 '<iframe src="' + embed.src + '" width="' + embed.width + '" height="' + embed.height + 305 '" frameborder="0" scrolling="no" allowfullscreen></iframe>' 306 ); 307 return false; 308 }); 309 310 // Jodit's video dialog bakes in a fixed pixel width/height (400x345, 311 // regardless of whether the source is landscape or a vertical Short/ 312 // Reel/TikTok clip), and drag-resizing an iframe (unlike an image) sets 313 // fixed pixel style instead of updating those attributes â either way 314 // it's a hardcoded box shape that doesn't scale. Convert whatever shape 315 // the embed currently is (from its attributes, or from a drag-resize) 316 // into an aspect-ratio and drop the fixed dimensions, so main.css's 317 // width:100%/height:auto can fill the container at the *correct* shape 318 // â horizontal, vertical, or anything else â on any screen size. 319 instance.events.on('change', function () { 320 var changed = false; 321 instance.editor.querySelectorAll('iframe').forEach(function (frame) { 322 var w = parseFloat(frame.style.width) || parseFloat(frame.getAttribute('width')); 323 var h = parseFloat(frame.style.height) || parseFloat(frame.getAttribute('height')); 324 var ratio = (w > 0 && h > 0) ? (w + ' / ' + h) : ''; 325 if (ratio && frame.style.aspectRatio !== ratio) { 326 frame.style.aspectRatio = ratio; 327 changed = true; 328 } 329 if (frame.style.width || frame.hasAttribute('width')) { 330 frame.style.width = ''; 331 frame.removeAttribute('width'); 332 changed = true; 333 } 334 if (frame.style.height || frame.hasAttribute('height')) { 335 frame.style.height = ''; 336 frame.removeAttribute('height'); 337 changed = true; 338 } 339 }); 340 if (changed) instance.synchronizeValues(); 341 }); 342 } 343 344 if (allowImages) { 345 // The bubble toolbar only appears over a text selection, so there's normally 346 // no way to insert an image on an empty line. This always-visible trigger 347 // reopens the same bubble (anchored at the caret) so 'image' is reachable 348 // either way. Only descriptionLong gets this â it's the only field where 349 // images are an option at all, so this is the only place it should show. 350 const trigger = document.createElement('button'); 351 trigger.type = 'button'; 352 trigger.className = 'rte-insert-trigger'; 353 trigger.title = 'Insertâ¦'; 354 trigger.textContent = '+'; 355 trigger.addEventListener('mousedown', function (e) { e.preventDefault(); }); 356 trigger.addEventListener('click', function (e) { 357 // Jodit closes any open popup on a window-level "outside click" (it lives 358 // outside .jodit-editor). Without stopping propagation here, the click that 359 // opens the popup also immediately closes it again in the same event. 360 e.stopPropagation(); 361 instance.s.focus(); 362 instance.e.fire('showPopup', 'insert', function () { 363 const rect = instance.s.range.getBoundingClientRect(); 364 if (rect.width || rect.height || rect.top || rect.left) return rect; 365 const node = instance.s.current() || instance.editor; 366 const el2 = node.nodeType === 3 ? node.parentElement : node; 367 return el2.getBoundingClientRect(); 368 }); 369 }); 370 instance.container.style.position = 'relative'; 371 instance.container.appendChild(trigger); 372 373 // Right-click "copy image" already pastes real HTML with an <img> tag, which 374 // flows through normally â this only catches copying the URL text itself, 375 // which otherwise lands as plain/linked text instead of an actual image. 376 instance.events.on('beforePaste', function (e) { 377 var dt = e.clipboardData || (e.originalEvent && e.originalEvent.clipboardData); 378 var text = dt && dt.getData('text/plain'); 379 var html = dt && dt.getData('text/html'); 380 if (!text || html) return; // let real HTML paste through untouched 381 var url = text.trim(); 382 if (!/^https?:\/\/\S+\.(jpe?g|png|gif|webp|avif|svg)(\?\S*)?(#\S*)?$/i.test(url)) return; 383 var img = instance.createInside.element('img'); 384 img.setAttribute('src', url); 385 instance.s.insertImage(img, null, instance.o.imageDefaultWidth); 386 return false;
387 }); 388 389 // On every change: scrub any base64 data: URI images (Jodit's own URL-insert 390 // dialog doesn't validate the scheme, so one pasted into the Src field would 391 // otherwise get embedded verbatim and balloon the stored HTML) and make sure 392 // every remaining <img> has loading="lazy" baked in, regardless of how it was 393 // inserted (URL dialog, pasted URL, etc.) â enforced here at edit time only, 394 // nothing changes at render time. 395 instance.events.on('change', function () { 396 var changed = false; 397 instance.editor.querySelectorAll('img').forEach(function (img) { 398 var src = img.getAttribute('src') || ''; 399 if (src.indexOf('data:') === 0) { 400 img.remove(); 401 changed = true; 402 return; 403 } 404 if (!img.hasAttribute('loading')) { 405 img.setAttribute('loading', 'lazy'); 406 changed = true; 407 } 408 }); 409 if (changed) instance.synchronizeValues(); 410 }); 411 } 412 413 editors.set(key, instance); 414 }); 415 }; 416 417 // Jodit automatically syncs content back to the original textarea â no flush needed. 418 window.flushRteEditors = function () {}; 419 420 window.setRteValue = function (key, html) { 421 var instance = editors.get(key); 422 if (instance) instance.value = html || ''; 423 }; 424 425 // Read through the editor rather than the textarea â the textarea only holds 426 // whatever Jodit last synchronized, which lags mid-edit. 427 window.getRteValue = function (key) { 428 var instance = editors.get(key); 429 return instance ? instance.value : null; 430 }; 431 432 window.destroyRteEditors = function () { 433 editors.forEach(function (instance) { instance.destruct(); }); 434 editors.clear(); 435 }; 436})(); 437 438// --------------------------------------------------------------------------- 439// Rotator â cycling multilingual phrase display 440// --------------------------------------------------------------------------- 441(function () { 442 function initRotator() { 443 var section = document.querySelector('.rotator-section'); 444 if (!section) return; 445 var phrases = window.__rotatorPhrases; 446 if (!phrases || !phrases.length) return; 447 448 var btn = section.querySelector('.rotator-toggle'); 449 var phraseEl = section.querySelector('.rotator-current-phrase'); 450 var langEl = section.querySelector('.rotator-current-lang'); 451 var list = section.querySelector('.rotator-list'); 452 if (!btn || !phraseEl || !langEl || !list) return; 453 454 var idx = 0; 455 var expanded = false; 456 var intervalId = null; 457 458 function showPhrase(i) { 459 // Fade out 460 phraseEl.style.opacity = '0'; 461 langEl.style.opacity = '0'; 462 setTimeout(function () { 463 idx = i % phrases.length; 464 phraseEl.textContent = phrases[idx].phrase; 465 langEl.textContent = phrases[idx].language; 466 // Fade in 467 phraseEl.style.opacity = '1'; 468 langEl.style.opacity = '1'; 469 }, 300); 470 } 471 472 function startCycling() { 473 intervalId = setInterval(function () { 474 showPhrase(idx + 1); 475 }, 4000); 476 } 477 478 function stopCycling() { 479 if (intervalId) { 480 clearInterval(intervalId); 481 intervalId = null; 482 } 483 } 484 485 // Wire up opacity transition via inline style (CSS handles duration) 486 phraseEl.style.transition = 'opacity 0.3s ease-in-out'; 487 langEl.style.transition = 'opacity 0.3s ease-in-out'; 488 489 btn.addEventListener('click', function () { 490 expanded = !expanded; 491 btn.setAttribute('aria-expanded', String(expanded)); 492 if (expanded) { 493 stopCycling(); 494 list.hidden = false; 495 // reset to first phrase when expanding (match original behaviour) 496 showPhrase(0); 497 } else { 498 list.hidden = true; 499 startCycling(); 500 } 501 }); 502 503 startCycling(); 504 } 505 506 document.addEventListener('DOMContentLoaded', initRotator); 507})(); 508 509// --------------------------------------------------------------------------- 510// Plausible event tracking utility 511// --------------------------------------------------------------------------- 512window.trackEvent = function (eventName, props) { 513 window.plausible(eventName, { props: props || {} }); 514}; 515 516// --------------------------------------------------------------------------- 517// Shared form-validation hints 518// Used by Alpine x-data forms (contact, submit, suggest, sponsors, subscribe, 519// tip) to explain *why* a required field or the submit button is blocked, 520// instead of a disabled button giving silent no feedback. 521// --------------------------------------------------------------------------- 522window.GRM = window.GRM || {}; 523 524// Returns '' when the email is valid or empty, otherwise a specific, 525// human-readable reason (e.g. catches the classic "space typed into the 526// email field" case that otherwise just silently disables the submit button). 527GRM.emailIssue = function (value) { 528 const v = (value || '').trim(); 529 if (!v) return ''; 530 if (/\s/.test(v)) return 'Remove the space in your email address.'; 531 if (v.indexOf('@') === -1) return 'Email needs an "@" symbol.'; 532 if (v.split('@').length > 2) return 'Email can only have one "@" symbol.';
533 const domain = v.split('@')[1] || ''; 534 if (domain.indexOf('.') === -1) return 'Email needs a domain, like ".com".'; 535 const tld = domain.slice(domain.lastIndexOf('.') + 1); 536 if (!tld) return 'Email needs a suffix after the dot, like ".com".'; 537 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return 'Enter a valid email address.'; 538 return ''; 539}; 540 541GRM.isValidEmail = function (value) { 542 const v = (value || '').trim(); 543 return !!v && !GRM.emailIssue(v); 544}; 545 546// Per-field hint for a required text input. 547GRM.requiredHint = function (value) { 548 return (value || '').trim() ? '' : 'This field is required.'; 549}; 550 551// Per-field hint for a required email input (required + format check combined). 552GRM.emailHint = function (value) { 553 if (!(value || '').trim()) return 'This field is required.'; 554 return GRM.emailIssue(value); 555}; 556 557// Joins ['a'] -> 'a', ['a','b'] -> 'a and b', ['a','b','c'] -> 'a, b, and c'. 558GRM.joinList = function (arr) { 559 if (arr.length === 0) return ''; 560 if (arr.length === 1) return arr[0]; 561 if (arr.length === 2) return arr[0] + ' and ' + arr[1]; 562 return arr.slice(0, -1).join(', ') + ', and ' + arr[arr.length - 1]; 563};
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.