PageSourceSearch

https://getrawmilk.com/js/main.js

js getrawmilk.com collected 2026-10-02 06:28:46 UTC 26,080 bytes, 563 lines download raw bytes

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(/&nbsp;/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(/&nbsp;/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.