PageSourceSearch

https://rtlcc.org/scripts/analytics-contract-v1.js

js rtlcc.org collected 2026-09-27 02:04:40 UTC 19,356 bytes, 561 lines download raw bytes

1export const ANALYTICS_SCHEMA_VERSION = 1;
2
3export const ANALYTICS_EVENT_TYPES = [
4  "page_view",
5  "cta_click",
6  "story_view",
7  "story_engaged",
8  "resource_view",
9  "outbound_help_click",
10  "form_start",
11  "form_submit",
12  "volunteer_signup",
13  "email_signup",
14  "sms_signup",
15  "conversion",
16  "donation_click",
17  "donation_complete",
18];
19
20export const ANALYTICS_EVENT_TYPE_SET = new Set(ANALYTICS_EVENT_TYPES);
21
22// Campaign-shaped values (utm_source, utm_campaign, utm_content, and the
23// campaign_id property) validate by shape, not enumeration, so campaigns
24// minted in Shepherd's campaign registry work without a website deploy.
25// Shepherd's ingest stays the authoritative allowlist for which campaigns
26// exist. utm_medium is a fixed taxonomy and utm_term is locked (never
27// accepted); both still enumerate.
28export const ANALYTICS_CAMPAIGN_SLUG_PATTERN = /^(?=.*[a-z])[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/u;
29
30export const ANALYTICS_UTM_ENUMS = {
31  utm_medium: new Set(["qr", "print", "email", "campaign_page"]),
32  utm_term: new Set(),
33};
34
35export const ANALYTICS_UTM_KEYS = [
36  "utm_source",
37  "utm_medium",
38  "utm_campaign",
39  "utm_content",
40  "utm_term",
41];
42
43export function isAnalyticsCampaignSlug(value) {
44  return typeof value === "string" && ANALYTICS_CAMPAIGN_SLUG_PATTERN.test(value);
45}
46
47const STATIC_PATHS = new Set([
48  "/",
49  "/404",
50  "/about",
51  "/christmas",
52  "/contact",
53  "/donate",
54  "/donate/thank-you",
55  "/education",
56  "/events",
57  "/get-involved",
58  "/impact",
59  "/legacy",
60  "/lifehouse",
61  "/lifehouse-podcast",
62  "/madera-fair",
63  "/madera-fair/get-involved",
64  "/madera-fair/the-300",
65  "/op-eds",
66  "/pregnancy-help",
67  "/privacy",
68  "/rachels-vineyard",
69  "/radio",
70  "/samples/bloomerang-donate",
71  "/sidewalk-stories",
72  "/sms",
73  "/student-workshop",
74  "/terms",
75  "/the-300",
76  "/updates",
77  "/updates/july-2026-newsletter",
78  "/ways-to-give",
79  "/ways-to-give/vehicle-donation",
80  "/why-life",
81  "/es",
82  "/es/404",
83  "/es/about",
84  "/es/christmas",
85  "/es/contact",
86  "/es/donate",
87  "/es/donate/thank-you",
88  "/es/education",
89  "/es/events",
90  "/es/get-involved",
91  "/es/impact",
92  "/es/legacy",
93  "/es/lifehouse",
94  "/es/lifehouse-podcast",
95  "/es/madera-fair",
96  "/es/madera-fair/get-involved",
97  "/es/madera-fair/the-300",
98  "/es/op-eds",
99  "/es/pregnancy-help",
100  "/es/privacy",
101  "/es/rachels-vineyard",
102  "/es/radio",
103  "/es/sidewalk-stories",
104  "/es/sms",
105  "/es/student-workshop",
106  "/es/terms",
107  "/es/the-300",
108  "/es/updates",
109  "/es/updates/july-2026-newsletter",
110  "/es/ways-to-give",
111  "/es/ways-to-give/vehicle-donation",
112  "/es/why-life",
113]);
114
115const OP_ED_SLUGS = new Set([
116  "2014-05-05-josh-brahm-dialogues-with-a-fresno-city-college-biology-professor",
117  "2014-09-14-can-you-explain-why-youre-pro-life-in-a-30-second-sound-bite",
118  "2014-10-03-the-most-undervalued-argument-in-the-prolife-movement",
119  "2014-10-27-what-happens-in-dc-doesnt-stay-in-dc",
120  "2014-11-07-election-2014-huge-victories-a-few-defeats-how-the-midterms-have-and-will-impact-the-pro-life-movement",
121  "2015-02-20-pencils-prisons-and-the-abortion-provider",
122  "2015-03-15-sex-selective-abortion-in-the-us",
123  "2015-07-23-7things",
124  "2015-07-31-press-conference-monday-august-3-2015",
125  "2015-08-04-watch-the-abortion-provider-videos",
126  "2016-11-11-prolife-response-presidential-election",
127  "2017-05-04-bad-california-state-bills",
128  "2017-05-08-ahca-and-religious-liberty-eo",
129  "2017-05-13-standing-up-for-speech-fresno-state-students-for-life",
130  "2017-05-23-the-danger-of-sb-481",
131  "2017-06-02-correcting-bias-on-hhs-mandate-and-assisted-suicide-in-todays-fresno-bee",
132  "2017-06-08-francis-collins-to-nih-trumps-first-pro-life-stumble",
133  "2017-06-12-response-to-fresno-bee-article-on-hhs-contraception-mandate",
134  "2017-11-09-whats-next-for-california-and-pro-lifers",
135  "2018-01-15-stop-sb-320-no-abortion-clinics-on-college-campuses",
136  "2018-06-26-going-to-the-capitol",
137  "2018-06-29-justice-anthony-kennedy-retiring",
138  "2018-07-11-new-supreme-court-justice-brett-kavanaugh",
139  "2018-08-22-the-rise-of-chemical-abortion-and-telemedicine",
140  "2018-08-31-sb-320-moves-forward",
141  "2018-09-04-urge-jerry-brown-to-veto-sb-320",
142  "2018-10-31-the-2018-elections-what-is-at-stake",
143  "2018-11-08-election-results",
144  "2019-02-01-thoughts-on-the-new-late-term-abortion-law",
145  "2019-04-10-abortion-ideology-running-roughshod-over-common-sense-in-state-legislature",
146  "2019-08-21-oppose-medication-abortion-on-csu-and-uc-campuses",
147  "2019-10-07-urge-gov-newsom-veto-sb-24",
148  "2020-03-12-prop-71-dumped-billions-into-useless-research-californians-should-not-re-up-this-november",
149  "2020-04-15-fresno-abortion-provider-sold-babies-bodies",
150  "2020-05-21-insane-pro-choice-bills",
151  "2020-07-03-the-abortion-distortion",
152  "2020-07-13-proposition-14-vote-no",
153  "2020-11-16-election-2020-abortion-policy-results",
154  "2021-06-04-dobbs-v-jackson-womens-health-organization",
155  "2021-06-18-roe-v-wade",
156  "2021-07-15-doe-v-bolton",
157  "2021-09-21-texas-abortion-law",
158  "2022-05-06-rlcc-statement-on-leak-of-draft-supreme-court-opinion",
159  "2022-06-24-statement-on-dobbs-v-jackson-womens-health-organization",
160  "2022-10-12-opposing-proposition-1",
161  "2022-10-28-tilting-the-scales",
162  "2022-12-13-records-request-shows-abortion-provider-grant-can-fund-abortion-directly",
163  "2023-03-01-stop-abortion-provider",
164  "2023-05-19-ab-710-315-are-defeated",
165  "2023-07-05-the-big-lie-of-proposition-1",
166  "2023-07-27-opposing-extremist-abortion-policies-in-the-military",
167  "2026-05-19-trumps-conflicted-views-are-complicating-abortion-policy",
168  "2026-06-25-right-to-life-report-june-2026",
169]);
170
171const UPDATE_SLUGS = new Set([
172  "2014-05-13-greg-peterson-on-how-rlcc-has-changed-his-life",
173  "2014-07-21-5-tips-talking-to-your-teens-about-pregnancy",
174  "2015-09-14-conversations-at-the-justice-for-all-outreach",
175  "2015-09-16-why-we-ask-for-your-support",
176  "2015-11-13-3-lives-saved",
177  "2018-04-23-talking-with-teens",
178  "2018-04-26-directors-note",
179  "2018-09-12-end-of-life-treatments-and-documents-what-you-need-to-know",
180  "2018-10-22-fresno-fair-recap",
181  "2019-05-02-right-to-life-radio-may-4-2019",
182  "2019-05-22-may-18-2019-right-to-life-radio",
183  "2020-03-23-new-video-series",
184  "2020-07-08-temporary-suspension-of-sidewalk-ministry",
185  "2020-10-14-in-memoriam-debby-bone",
186  "2020-12-12-ethical-guidance-on-covid-vaccines",
187  "2022-05-10-a-father-looks-at-mothers-day",
188  "2022-06-01-mom-choosing-life",
189  "2023-09-14-what-parents-need-to-know-children-abortion-contraception-and-parental-rights",
190]);
191
192export const ANALYTICS_STORY_SLUGS = new Set([
193  "2026-03-07-zayda",
194  "2026-04-11-harmony",
195  "2026-05-09-kiana",
196  "2026-05-29-maya",
197  "2026-07-08-eva",
198  "2026-08-03-naomi",
199]);
200
201const CTA_IDS = new Set([
202  "donate",
203  "the_300_monthly_submit",
204  "christmas_2026_tickets",
205  "christmas_2026_sponsorship_packet",
206  "christmas_home_hero_event",
207  "christmas_home_hero_donate",
208  "christmas_home_lifehouse",
209  "madera_fair_hub_volunteer",
210  "madera_fair_hub_support",
211  "madera_fair_hub_get_involved",
212  "madera_fair_hub_the_300",
213  "madera_fair_hub_share",
214  "madera_fair_hub_email_invite",
215  "madera_fair_get_involved_back",
216  "madera_fair_get_involved_submit",
217  "madera_fair_get_involved_start_another",
218  "madera_fair_hub_shift_thursday_5_7",
219  "madera_fair_hub_shift_thursday_7_9",
220  "madera_fair_hub_shift_thursday_9_11",
221  "madera_fair_hub_shift_friday_5_7",
222  "madera_fair_hub_shift_friday_7_9",
223  "madera_fair_hub_shift_friday_9_11",
224  "madera_fair_hub_shift_saturday_3_5",
225  "madera_fair_hub_shift_saturday_5_7",
226  "madera_fair_hub_shift_saturday_7_9",
227  "madera_fair_hub_shift_saturday_9_11",
228  "madera_fair_hub_shift_sunday_3_5",
229  "madera_fair_hub_shift_sunday_5_7",
230  "madera_fair_hub_shift_sunday_7_9",
231  "madera_fair_hub_shift_sunday_9_11",
232]);
233
234export const ANALYTICS_PROPERTY_VALUE_ALLOWLISTS = {
235  cta_id: CTA_IDS,
236  form_id: new Set([
237    "form",
238    "madera_fair_get_involved",
239    "madera_fair_the_300",
240  ]),
241  resource_id: new Set(["pregnancy_help", "lifehouse", "rachels_vineyard"]),
242  story_slug: ANALYTICS_STORY_SLUGS,
243  funnel_step: new Set([
244    "get_involved_complete",
245    "donation_complete",
246    "the_300_checkout",
247    "the_300_complete",
248  ]),
249  frequency: new Set(["one-time", "monthly"]),
250  amount_bucket: new Set(["25", "26_to_49", "50_to_99", "100_plus"]),
251  placement: new Set([
252    "hero",
253    "volunteer_schedule",
254    "path_cards",
255    "invite_panel",
256    "madera_fair_checkout",
257  ]),
258  donation_program: new Set(["general", "the_300"]),
259};
260
261const EVENT_OUTCOMES = {
262  story_engaged: new Set(["engaged"]),
263  volunteer_signup: new Set(["success", "volunteer_signup"]),
264  email_signup: new Set(["success", "email_signup"]),
265  sms_signup: new Set(["success", "sms_signup"]),
266  conversion: new Set([
267    "success",
268    "volunteer_signup",
269    "email_signup",
270    "sms_signup",
271  ]),
272  donation_complete: new Set(["success"]),
273};
274
275const EVENT_PROPERTY_ALLOWLISTS = {
276  page_view: new Set(["campaign_id"]),
277  cta_click: new Set(["cta_id", "destination_path", "placement", "funnel_step", "campaign_id"]),
278  story_view: new Set(["story_slug", "campaign_id"]),
279  story_engaged: new Set(["story_slug", "outcome", "campaign_id"]),
280  resource_view: new Set(["resource_id", "campaign_id"]),
281  outbound_help_click: new Set(["cta_id", "destination_path", "resource_id", "placement", "campaign_id"]),
282  form_start: new Set(["form_id", "funnel_step", "placement", "campaign_id"]),
283  form_submit: new Set(["form_id", "funnel_step", "placement", "campaign_id"]),
284  volunteer_signup: new Set(["form_id", "funnel_step", "outcome", "placement", "campaign_id"]),
285  email_signup: new Set(["form_id", "funnel_step", "outcome", "placement", "campaign_id"]),
286  sms_signup: new Set(["form_id", "funnel_step", "outcome", "placement", "campaign_id"]),
287  conversion: new Set([
288    "form_id",
289    "funnel_step",
290    "frequency",
291    "amount_bucket",
292    "outcome",
293    "placement",
294    "campaign_id",
295    "donation_program",
296  ]),
297  donation_click: new Set([
298    "cta_id",
299    "destination_path",
300    "funnel_step",
301    "frequency",
302    "amount_bucket",
303    "placement",
304    "campaign_id",
305    "donation_program",
306  ]),
307  donation_complete: new Set([
308    "funnel_step",
309    "frequency",
310    "amount_bucket",
311    "outcome",
312    "placement",
313    "campaign_id",
314    "donation_program",
315  ]),
316};
317
318const PROPERTY_LIMITS = {
319  cta_id: 80,
320  destination_path: 240,
321  form_id: 80,
322  resource_id: 80,
323  story_slug: 120,
324  funnel_step: 80,
325  frequency: 32,
326  amount_bucket: 32,
327  outcome: 32,
328  placement: 80,
329  campaign_id: 80,
330  donation_program: 80,
331};
332
333const SAFE_ROUTE_PATH_PATTERN = /^\/(?:[a-z0-9][a-z0-9._~-]*(?:\/[a-z0-9][a-z0-9._~-]*)*)?$/u;
334const SAME_SITE_REFERRER_HOSTS = new Set([
335  "righttolifeca.org",
336  "www.righttolifeca.org",
337  "rtlca.org",
338  "www.rtlca.org",
339]);
340const SENSITIVE_ROUTE_ROOTS = [
341  "/pregnancy-help",
342  "/es/pregnancy-help",
343  "/lifehouse",
344  "/es/lifehouse",
345  "/rachels-vineyard",
346  "/es/rachels-vineyard",
347];
348
349export const ANALYTICS_EVENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
350export const ANALYTICS_TOUCH_MAX_AGE_MS = 400 * 24 * 60 * 60 * 1_000;
351export const ANALYTICS_TOUCH_ROTATION_AGE_MS = 393 * 24 * 60 * 60 * 1_000;
352export const ANALYTICS_FUTURE_SKEW_MS = 5 * 60 * 1_000;
353
354const ID_PATTERNS = {
355  e: /^e_[0-9a-f]{32}$/u,
356  v: /^v_[0-9a-f]{32}$/u,
357  s: /^s_[0-9a-f]{32}$/u,
358};
359
360function cleanString(value, maxLength) {
361  if (typeof value !== "string") return undefined;
362  const cleaned = value.replace(/[\u0000-\u001f\u007f]/gu, "").trim();
363  return cleaned && cleaned.length <= maxLength ? cleaned : undefined;
364}
365
366function hasUnsafePathSegment(path) {
367  if (path.includes("\\")) return true;
368  for (const rawSegment of path.split("/")) {
369    let segment = rawSegment;
370    for (let pass = 0; pass < 2; pass += 1) {
371      try {
372        const decoded = decodeURIComponent(segment);
373        if (decoded === segment) break;
374        segment = decoded;
375      } catch {
376        return true;
377      }
378    }
379    if (segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\")) {
380      return true;
381    }
382  }
383  return false;
384}
385
386export function isKnownAnalyticsPath(path) {
387  if (STATIC_PATHS.has(path)) return true;
388  const match = path.match(/^\/(?:es\/)?(op-eds|updates|sidewalk-stories)\/([^/]+)$/u);
389  if (!match) return false;
390  const [, collection, slug] = match;
391  if (collection === "op-eds") return OP_ED_SLUGS.has(slug);
392  if (collection === "updates") return UPDATE_SLUGS.has(slug);
393  return ANALYTICS_STORY_SLUGS.has(slug);
394}
395
396export function canonicalAnalyticsPath(value) {
397  const parsed = cleanString(value, 240);
398  if (
399    !parsed ||
400    !parsed.startsWith("/") ||
401    parsed.startsWith("//") ||
402    parsed.includes("?") ||
403    parsed.includes("#") ||
404    hasUnsafePathSegment(parsed)
405  ) {
406    return undefined;
407  }
408  if (parsed === "/") return parsed;
409  const collapsed = parsed.replace(/\/{2,}/gu, "/");
410  const canonical = collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
411  if (!SAFE_ROUTE_PATH_PATTERN.test(canonical)) return undefined;
412  if (SENSITIVE_ROUTE_ROOTS.some((root) => canonical.startsWith(`${root}/`))) return undefined;
413  return isKnownAnalyticsPath(canonical) ? canonical : undefined;
414}
415
416export function cleanAnalyticsHostname(value) {
417  const parsed = cleanString(value, 120)?.toLowerCase();
418  if (!parsed) return undefined;
419  const labels = parsed.split(".");
420  const validHostname = labels.every(
421    (label) =>
422      label.length >= 1 &&
423      label.length <= 63 &&
424      /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label),
425  );
426  const isIpv4 = labels.length === 4 && labels.every((label) => /^\d{1,3}$/u.test(label));
427  if (
428    !validHostname ||
429    !/[a-z]/u.test(parsed) ||
430    isIpv4 ||
431    parsed.includes(":") ||
432    parsed.includes("/") ||
433    parsed.includes("@") ||
434    parsed.includes(" ")
435  ) {
436    return undefined;
437  }
438  return parsed;
439}
440
441export function isAnalyticsId(value, prefix) {
442  return typeof value === "string" && Boolean(ID_PATTERNS[prefix]?.test(value));
443}
444
445export function cleanAnalyticsTimestamp(value, maxAgeMs, nowMs = Date.now()) {
446  const parsed = cleanString(value, 40);
447  if (!parsed) return undefined;
448  const timestamp = new Date(parsed);
449  const time = timestamp.getTime();
450  if (!Number.isFinite(time) || timestamp.toISOString() !== parsed) return undefined;
451  if (time > nowMs + ANALYTICS_FUTURE_SKEW_MS || nowMs - time > maxAgeMs) return undefined;
452  return parsed;
453}
454
455export function cleanAnalyticsCampaignValue(key, value) {
456  const parsed = cleanString(value, 100);
457  if (!parsed || !ANALYTICS_UTM_KEYS.includes(key)) return undefined;
458  const enumValues = ANALYTICS_UTM_ENUMS[key];
459  if (enumValues) return enumValues.has(parsed) ? parsed : undefined;
460  return isAnalyticsCampaignSlug(parsed) ? parsed : undefined;
461}
462
463export function sanitizeAnalyticsTouch(value, maxAgeMs = ANALYTICS_TOUCH_MAX_AGE_MS, nowMs = Date.now()) {
464  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
465  const landingPath = canonicalAnalyticsPath(value.landing_path);
466  const occurredAt = cleanAnalyticsTimestamp(value.occurred_at, maxAgeMs, nowMs);
467  if (!landingPath || !occurredAt) return undefined;
468
469  const output = { landing_path: landingPath, occurred_at: occurredAt };
470  const referrerHost = cleanAnalyticsHostname(value.referrer_host);
471  if (referrerHost) output.referrer_host = referrerHost;
472  if (value.referrer_path !== undefined) {
473    const referrerPath = canonicalAnalyticsPath(value.referrer_path);
474    if (referrerPath && referrerHost && SAME_SITE_REFERRER_HOSTS.has(referrerHost)) {
475      output.referrer_path = referrerPath;
476    }
477  }
478  for (const key of ANALYTICS_UTM_KEYS) {
479    const campaignValue = cleanAnalyticsCampaignValue(key, value[key]);
480    if (campaignValue) output[key] = campaignValue;
481  }
482  return output;
483}
484
485export function isAllowedAnalyticsPropertyValue(key, value, eventType) {
486  if (typeof value !== "string") return false;
487  if (key === "outcome") return Boolean(EVENT_OUTCOMES[eventType]?.has(value));
488  if (key === "campaign_id") return isAnalyticsCampaignSlug(value);
489  return Boolean(ANALYTICS_PROPERTY_VALUE_ALLOWLISTS[key]?.has(value));
490}
491
492export function sanitizeAnalyticsProperties(eventType, value) {
493  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
494  const allowedKeys = EVENT_PROPERTY_ALLOWLISTS[eventType];
495  if (!allowedKeys) return {};
496  const output = {};
497  for (const key of allowedKeys) {
498    const maxLength = PROPERTY_LIMITS[key];
499    if (key === "destination_path") {
500      const path = canonicalAnalyticsPath(value[key]);
501      if (path) output[key] = path;
502      continue;
503    }
504    const parsed = cleanString(value[key], maxLength);
505    if (parsed && isAllowedAnalyticsPropertyValue(key, parsed, eventType)) output[key] = parsed;
506  }
507  return output;
508}
509
510export function sanitizeAnalyticsEvent(value, nowMs = Date.now()) {
511  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
512  if (value.schema_version !== ANALYTICS_SCHEMA_VERSION) return null;
513  if (!ANALYTICS_EVENT_TYPE_SET.has(value.event_type)) return null;
514  if (!isAnalyticsId(value.event_id, "e")) return null;
515  if (!isAnalyticsId(value.visitor_id, "v")) return null;
516  if (!isAnalyticsId(value.session_id, "s")) return null;
517
518  const occurredAt = cleanAnalyticsTimestamp(value.occurred_at, ANALYTICS_EVENT_MAX_AGE_MS, nowMs);
519  const pagePath = canonicalAnalyticsPath(value.page?.path);
520  const firstTouch = sanitizeAnalyticsTouch(value.attribution?.first_touch, ANALYTICS_TOUCH_MAX_AGE_MS, nowMs);
521  const currentTouch = sanitizeAnalyticsTouch(value.attribution?.current_touch, ANALYTICS_TOUCH_MAX_AGE_MS, nowMs);
522  if (!occurredAt || !pagePath || !firstTouch || !currentTouch) return null;
523  if (Date.parse(firstTouch.occurred_at) > Date.parse(currentTouch.occurred_at)) return null;
524  if (Date.parse(currentTouch.occurred_at) > Date.parse(occurredAt)) return null;
525
526  const page = { path: pagePath };
527  const title = cleanString(value.page?.title, 160);
528  if (title) page.title = title;
529  if (value.page?.language === "en" || value.page?.language === "es") {
530    page.language = value.page.language;
531  }
532  const previousPath = canonicalAnalyticsPath(value.page?.previous_path);
533  if (previousPath) page.previous_path = previousPath;
534  const referrerHost = cleanAnalyticsHostname(value.page?.referrer_host);
535  if (referrerHost) page.referrer_host = referrerHost;
536  const referrerPath = canonicalAnalyticsPath(value.page?.referrer_path);
537  if (referrerPath && referrerHost && SAME_SITE_REFERRER_HOSTS.has(referrerHost)) {
538    page.referrer_path = referrerPath;
539  }
540
541  const properties = sanitizeAnalyticsProperties(value.event_type, value.properties);
542  if (value.event_type === "story_view" || value.event_type === "story_engaged") {
543    const pageStorySlug = pagePath.match(/^\/(?:es\/)?sidewalk-stories\/([^/]+)$/u)?.[1];
544    if (!pageStorySlug || properties.story_slug !== pageStorySlug) return null;
545  }
546
547  return {
548    schema_version: ANALYTICS_SCHEMA_VERSION,
549    event_id: value.event_id,
550    visitor_id: value.visitor_id,
551    session_id: value.session_id,
552    event_type: value.event_type,
553    occurred_at: occurredAt,
554    page,
555    attribution: {
556      first_touch: firstTouch,
557      current_touch: currentTouch,
558    },
559    properties,
560  };
561}

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.