1/** 2 * Shared utilities for the Ticket Fairy chat widgets. 3 * 4 * Two widgets share these primitives: 5 * - resources/js/sales-chat-widget.js (B2B, on-page, theticketfairy.com) 6 * - resources/js/concierge-widget-app.js (brand-embeddable, runs in iframe) 7 * 8 * The shells differ (sidebar+bubble vs iframe+panel, theming, auth model, 9 * tool sets) so this is intentionally NOT a framework â just the small 10 * hardened utilities both need to handle: 11 * - iOS Safari quirks (zoom-on-focus, keyboard-up viewport, disabled-input 12 * crash, send-button focus thrash) 13 * - Markdown rendering (DOM-based; createElement only â no innerHTML on 14 * model output, ever) 15 * - Textarea autoresize that doesn't break under content-box rounding 16 * 17 * Each widget defines its own DOM shell and event wiring, then imports 18 * these via `window.TtfChatCore`. Loaded as an ordinary <script> before 19 * the widget's own script tag. 20 * 21 * Idempotent: re-loading the file in the same window leaves the existing 22 * namespace alone (relevant if a host page accidentally includes this 23 * twice, or if a parent + iframe both load it). 24 */ 25(function (global) { 26 'use strict'; 27 28 if (global.TtfChatCore) { 29 return; 30 } 31 32 // --------------------------------------------------------------------- 33 // iOS Safari detection 34 // --------------------------------------------------------------------- 35 // Three quirks the rest of this module exists to defuse: 36 // 1. Toggling input.disabled during streaming send cycles can crash 37 // Mobile Safari mid-stream â use input.readOnly instead. 38 // 2. Programmatic input.focus() after async DOM updates leaves the 39 // keyboard in inconsistent states ("can't type after first 40 // message"); on iOS we let users tap to refocus. 41 // 3. A click handler on the send button steals focus from the 42 // textarea, triggering keyboard open/close churn. Bind 43 // touchstart + preventDefault on iOS so the textarea keeps focus 44 // while the message goes (each widget wires this in its own 45 // shell â utility lives at the widget layer because the send 46 // button is widget-specific). 47 function isMobileSafari() { 48 var ua = global.navigator && global.navigator.userAgent || ''; 49 var isAppleMobile = /iP(hone|od|ad)/.test(ua) || 50 (/Macintosh/.test(ua) && 'ontouchend' in (global.document || {})); 51 var isWebKit = /WebKit/i.test(ua); 52 var isChromeiOS = /CriOS/i.test(ua); 53 var isFirefoxiOS = /FxiOS/i.test(ua); 54 var isEdgeiOS = /EdgiOS/i.test(ua); 55 return isAppleMobile && isWebKit && !isChromeiOS && !isFirefoxiOS && !isEdgeiOS; 56 } 57 58 // --------------------------------------------------------------------- 59 // Sending lock 60 // --------------------------------------------------------------------- 61 // Lock/unlock input + send button around an in-flight send. iOS 62 // Safari path uses readOnly (preserves keyboard / cursor); other 63 // browsers use disabled (stronger affordance â greyed out). 64 // 65 // `opts.disableSendOnEmpty` mirrors the sales-chat behaviour: when 66 // unlocking, leave the send button disabled if the input is empty. 67 // Concierge unlocks send unconditionally. 68 function setSendingLock(input, sendBtn, locked, opts) { 69 opts = opts || {}; 70 if (input) { 71 if (isMobileSafari()) { 72 input.disabled = false; 73 input.readOnly = !!locked; 74 } else { 75 input.readOnly = false; 76 input.disabled = !!locked; 77 } 78 } 79 if (sendBtn) { 80 if (locked) { 81 sendBtn.disabled = true; 82 } else if (opts.disableSendOnEmpty && input) { 83 sendBtn.disabled = !((input.value || '').trim()); 84 } else { 85 sendBtn.disabled = false; 86 } 87 } 88 } 89 90 // --------------------------------------------------------------------- 91 // Focus management 92 // --------------------------------------------------------------------- 93 // Skip programmatic focus() on iOS Safari â repeated focus() around 94 // async DOM updates leaves the keyboard in inconsistent states. On 95 // other platforms a quiet focus() restores typing affordance after 96 // a send cycle. 97 // 98 // `opts.preventScroll` (default true) avoids the scroll-jump that 99 // some browsers trigger when refocusing. 100 function focusInputSafely(input, opts) { 101 opts = opts || {}; 102 if (!input || input.disabled) { 103 return; 104 } 105 if (isMobileSafari()) { 106 return; 107 } 108 try { 109 input.focus(opts.preventScroll === false ? undefined : { preventScroll: true }); 110 } catch (e) { 111 // Some browsers throw on focus() in detached iframes; ignore. 112 } 113 } 114 115 // --------------------------------------------------------------------- 116 // Textarea autoresize 117 // --------------------------------------------------------------------- 118 // Grow the textarea up to `maxPx` then enable internal scroll. 119 // 120 // Two non-obvious bits: 121 // 122 // - We RESET to 'auto' before measuring scrollHeight. Without this, 123 // scrollHeight stays at the previous explicit height and the 124 // textarea never shrinks back when the user deletes content. 125 // 126 // - We CACHE the natural scrollHeight in a local var BEFORE the 127 // style.height assignment. Reading scrollHeight a second time 128 // after height is set would be unreliable: the textarea uses 129 // content-box sizing, so the explicit height equals the content 130 // area and total clientHeight grows by vertical padding. That 131 // post-assignment scrollHeight reflects the inflated client area, 132 // not the actual content size â which would falsely trigger 133 // overflow:auto at ~3 lines of text instead of `maxPx`. 134 function autoResizeTextarea(textarea, maxPx) { 135 if (!textarea) return; 136 maxPx = maxPx || 100; 137 textarea.style.height = 'auto'; 138 var natural = textarea.scrollHeight; 139 textarea.style.height = Math.min(natural, maxPx) + 'px'; 140 textarea.style.overflowY = (natural > maxPx) ? 'auto' : 'hidden'; 141 } 142 143 // --------------------------------------------------------------------- 144 // Markdown rendering â DOM only 145 // --------------------------------------------------------------------- 146 // We NEVER use innerHTML on model output. Every node is built via 147 // createElement / createTextNode, so AI-produced strings cannot 148 // inject markup regardless of grammar holes. 149 // 150 // Block grammar: 151 // blank line â paragraph break (separate <div> blocks) 152 // "- item" / "* item" â <ul><li> 153 // "1. item" â <ol><li> 154 // anything else â <div class="ttf-cw-p"> with <br>s for 155 // single newlines inside the block 156 // 157 // Inline grammar (renderInline): 158 // **bold** / __bold__ â <strong> 159 // *italic* / _italic_ â <em> 160 // `code` â <code> 161 // 162 // `paragraphClassName` lets each widget tag its own paragraph wrapper 163 // (concierge: 'ttf-cc-p'; sales: 'ttf-sc-p') so existing CSS keeps 164 // working. 165 function renderMarkdown(raw, container, paragraphClassName) { 166 paragraphClassName = paragraphClassName || 'ttf-cw-p'; 167 var blocks = String(raw || '').split(/\n{2,}/); 168 169 for (var b = 0; b < blocks.length; b++) { 170 var block = blocks[b].replace(/^\n+|\n+$/g, ''); 171 if (!block) continue; 172 173 var lines = block.split('\n'); 174 var listKind = null; // 'ul' | 'ol' | null 175 176 if (lines.length > 0) {
177 if (allLinesMatch(lines, /^\s*[-*â¢]\s+/)) { 178 listKind = 'ul'; 179 } else if (allLinesMatch(lines, /^\s*\d+\.\s+/)) { 180 listKind = 'ol'; 181 } 182 } 183 184 if (listKind) { 185 var listEl = document.createElement(listKind); 186 for (var li = 0; li < lines.length; li++) { 187 if (!lines[li].trim()) continue; 188 var item = lines[li].replace(/^\s*([-*â¢]|\d+\.)\s+/, ''); 189 var liEl = document.createElement('li'); 190 renderInline(item, liEl); 191 listEl.appendChild(liEl); 192 } 193 container.appendChild(listEl); 194 } else { 195 var para = document.createElement('div'); 196 para.className = paragraphClassName; 197 for (var j = 0; j < lines.length; j++) { 198 if (j > 0) para.appendChild(document.createElement('br')); 199 renderInline(lines[j], para); 200 } 201 container.appendChild(para); 202 } 203 } 204 } 205 206 function allLinesMatch(lines, pattern) { 207 for (var i = 0; i < lines.length; i++) { 208 if (lines[i].trim() === '') continue; 209 if (!pattern.test(lines[i])) return false; 210 } 211 return true; 212 } 213 214 // Inline grammar walked left-to-right, longest delimiters first 215 // (** before *, __ before _) so "**bold**" doesn't get mis-parsed 216 // as italic-bold-italic. `code` short-circuits â anything inside 217 // backticks is rendered verbatim, no further inline parsing. 218 function renderInline(text, parent) { 219 text = String(text == null ? '' : text); 220 var i = 0; 221 var len = text.length; 222 var buf = ''; 223 224 function flushText() { 225 if (buf) { 226 parent.appendChild(document.createTextNode(buf)); 227 buf = ''; 228 } 229 } 230 231 while (i < len) { 232 // `code` â opaque 233 if (text.charAt(i) === '`') { 234 var close = text.indexOf('`', i + 1); 235 if (close > i) { 236 flushText(); 237 var code = document.createElement('code'); 238 code.textContent = text.substring(i + 1, close); 239 parent.appendChild(code); 240 i = close + 1; 241 continue; 242 } 243 } 244 245 // **bold** / __bold__ 246 if ((text.charAt(i) === '*' && text.charAt(i + 1) === '*') || 247 (text.charAt(i) === '_' && text.charAt(i + 1) === '_')) { 248 var marker = text.substr(i, 2); 249 var endBold = text.indexOf(marker, i + 2); 250 if (endBold > i + 2) { 251 flushText(); 252 var strong = document.createElement('strong'); 253 renderInline(text.substring(i + 2, endBold), strong); 254 parent.appendChild(strong); 255 i = endBold + 2; 256 continue; 257 } 258 } 259 260 // *italic* / _italic_ 261 if (text.charAt(i) === '*' || text.charAt(i) === '_') { 262 var ich = text.charAt(i); 263 // Don't open italic on a digit-or-letter boundary like 264 // snake_case or 5*5 â common false positives. 265 var prev = i > 0 ? text.charAt(i - 1) : ''; 266 var nextChar = text.charAt(i + 1); 267 var isWordPrev = /[A-Za-z0-9]/.test(prev); 268 var isSpaceNext = /\s/.test(nextChar) || nextChar === ''; 269 if (!isWordPrev && !isSpaceNext) { 270 var endItalic = text.indexOf(ich, i + 1); 271 if (endItalic > i + 1) { 272 flushText(); 273 var em = document.createElement('em'); 274 renderInline(text.substring(i + 1, endItalic), em); 275 parent.appendChild(em); 276 i = endItalic + 1; 277 continue; 278 } 279 } 280 } 281 282 buf += text.charAt(i); 283 i++; 284 } 285 flushText(); 286 } 287 288 // --------------------------------------------------------------------- 289 // Read-more clamp â pause long assistant replies that would fill the 290 // visible chat panel and flush the conversation off the screen. 291 // --------------------------------------------------------------------- 292 // 293 // Both chat widgets (concierge in iframe, sales chat in sidebar) run 294 // into the same UX failure when the AI writes too much: a reply that 295 // is taller than the messages container scrolls the start of the 296 // answer out of view, the visitor either has to scroll back up to 297 // read it or watches deltas race past during streaming, and the chat 298 // stops feeling like a chat. The skill prompts on both sides ask 299 // for short replies, but the model misjudges occasionally â packed 300 // event descriptions (lineups), long support answers, multi-bullet 301 // pricing summaries. This factory is the runtime safety net. 302 // 303 // When a reply overflows the threshold (default: 70% of the messages 304 // container clientHeight, min 140px): 305 // 306 // 1. Binary-search the sentence list for the largest prefix that 307 // fits the threshold. Falls back to word boundaries if no 308 // sentence boundary fits â better than clipping mid-word. 309 // 2. Render only that prefix in the bubble. Mark the bubble with 310 // a `clamped` class so the consumer's CSS can fade its bottom 311 // edge (the cut itself is at a sentence boundary so the text 312 // is whole; the fade is purely a visual hint). 313 // 3. Drop a "Continue reading" pill below the bubble. On tap, 314 // reveal the rest and re-clamp â long replies can hit a 315 // second pause cycle. 316 // 317 // State lives on the wrap/bubble DOM elements (`__ttfText`,
318 // `__ttfVisibleText`, `__ttfPendingText`, `__ttfPaused`) so the 319 // factory itself is stateless and one instance can manage every 320 // assistant bubble in a widget. 321 // 322 // Why a factory rather than direct exports: each widget brands its 323 // own class names (`ttf-cc-*` vs `ttf-sc-*`), surfaces its own 324 // localised label, and renders text its own way (concierge supports 325 // markdown, sales chat has an iOS textContent fast-path). The 326 // factory injects those callbacks once at setup so the per-call 327 // surface is tight. 328 // 329 // USAGE: 330 // 331 // var clamp = TtfChatCore.createReadMoreClamp({ 332 // getContainer: function () { return EL.messages; }, 333 // renderText: function (bubble, text) { renderMarkdown(text, bubble, 'ttf-cc-p'); }, 334 // getLabel: function () { return t('continue_reading', 'Continue reading'); }, 335 // getAriaLabel: function () { return t('continue_reading_aria', 'Continue reading the rest of this reply'); }, 336 // classNames: { 337 // clamped: 'ttf-cc-bubble-clamped', 338 // readMore: 'ttf-cc-read-more', 339 // cards: 'ttf-cc-cards', 340 // }, 341 // onAfterReveal: function (wrap) { settleScroll(wrap); }, 342 // }); 343 // 344 // clamp.apply(wrap, bubble, text); // non-streaming render + clamp 345 // clamp.onDelta(wrap, bubble, chunk); // streaming delta (per-delta clamp) 346 // clamp.onRetract(wrap, bubble, newText); // server told us to wipe and restart 347 // 348 // The reveal-button click handler is wired internally â consumers 349 // don't need to manage it. State on the wrap/bubble is also managed 350 // internally; consumers shouldn't write the __ttf* fields directly. 351 function createReadMoreClamp(cfg) { 352 cfg = cfg || {}; 353 var getContainer = cfg.getContainer; 354 var renderText = cfg.renderText; 355 var getLabel = cfg.getLabel || function () { return 'Continue reading'; }; 356 var getAriaLabel = cfg.getAriaLabel || function () { return 'Continue reading'; }; 357 var classNames = cfg.classNames || {}; 358 var classClamped = classNames.clamped || 'ttf-cw-bubble-clamped'; 359 var classReadMore = classNames.readMore || 'ttf-cw-read-more'; 360 var classCards = classNames.cards || null; 361 var heightRatio = (typeof cfg.heightRatio === 'number') ? cfg.heightRatio : 0.7; 362 var minThresholdPx = (typeof cfg.minThresholdPx === 'number') ? cfg.minThresholdPx : 140; 363 var onAfterReveal = (typeof cfg.onAfterReveal === 'function') ? cfg.onAfterReveal : null; 364 365 // Required-config guard. If a consumer forgets to pass a 366 // container/renderer the controller becomes a no-op rather 367 // than throwing â clamp is a UX enhancement, never a critical 368 // path, so failing soft beats taking down the whole widget. 369 if (typeof getContainer !== 'function' || typeof renderText !== 'function') { 370 var noop = function () {}; 371 return { apply: noop, onDelta: noop, onRetract: noop }; 372 } 373 374 function thresholdHeight() { 375 var c = getContainer(); 376 var ch = c ? c.clientHeight : 0; 377 return Math.max(minThresholdPx, ch * heightRatio); 378 } 379 380 // Locate the largest text-prefix length such that rendering 381 // text.slice(0, length) into `bubble` produces offsetHeight 382 // <= maxH. Tries sentence boundaries first (preserves a whole 383 // thought); falls back to word boundaries when no sentence 384 // fits (one-paragraph blocks, or a single huge sentence). 385 // Returns 0 when nothing usable fits â caller should NOT 386 // clamp in that case. 387 function findCut(text, bubble, maxH) { 388 if (!text) return 0; 389 var bounds = []; 390 // Sentence-ish boundary: end-punctuation (.!?â¦) optionally 391 // followed by closing quote/bracket, then whitespace; or a 392 // blank-line paragraph break. Permissive enough for smart 393 // quotes and closing parens. 394 var sentRe = /[.!?â¦]["')\]ââ]*\s+|\n\s*\n+/g; 395 var m; 396 while ((m = sentRe.exec(text)) !== null) { 397 bounds.push(m.index + m[0].length); 398 } 399 var fit = 0; 400 if (bounds.length) { 401 var lo = 0, hi = bounds.length - 1; 402 while (lo <= hi) { 403 var mid = (lo + hi) >> 1; 404 renderText(bubble, text.slice(0, bounds[mid])); 405 if (bubble.offsetHeight <= maxH) { 406 fit = bounds[mid]; 407 lo = mid + 1; 408 } else { 409 hi = mid - 1; 410 } 411 } 412 if (fit > 0) return fit; 413 } 414 var wordBounds = []; 415 var wre = /\s+/g; 416 while ((m = wre.exec(text)) !== null) { 417 wordBounds.push(m.index + m[0].length); 418 } 419 if (!wordBounds.length) return 0; 420 var loW = 0, hiW = wordBounds.length - 1, fitW = 0; 421 while (loW <= hiW) { 422 var midW = (loW + hiW) >> 1; 423 renderText(bubble, text.slice(0, wordBounds[midW])); 424 if (bubble.offsetHeight <= maxH) { 425 fitW = wordBounds[midW]; 426 loW = midW + 1; 427 } else { 428 hiW = midW - 1; 429 } 430 } 431 return fitW; 432 } 433 434 function setClamped(bubble, on) { 435 if (!bubble || !bubble.classList) return; 436 if (on) bubble.classList.add(classClamped); 437 else bubble.classList.remove(classClamped); 438 } 439 440 // Find the read-more pill anchored as a direct child of wrap. 441 // querySelector would also find pills inside nested cards, so
442 // walk only the immediate children. 443 function findButton(wrap) { 444 if (!wrap || !wrap.childNodes) return null; 445 for (var i = 0; i < wrap.childNodes.length; i++) { 446 var n = wrap.childNodes[i]; 447 if (n.nodeType === 1 && n.classList && n.classList.contains(classReadMore)) { 448 return n; 449 } 450 } 451 return null; 452 } 453 454 function removeButton(wrap) { 455 var btn = findButton(wrap); 456 if (btn && btn.parentNode) btn.parentNode.removeChild(btn); 457 } 458 459 function showButton(wrap, bubble) { 460 var existing = findButton(wrap); 461 if (existing) { 462 existing.textContent = getLabel(); 463 return existing; 464 } 465 var btn = document.createElement('button'); 466 btn.type = 'button'; 467 btn.className = classReadMore; 468 btn.setAttribute('aria-expanded', 'false'); 469 btn.setAttribute('aria-label', getAriaLabel()); 470 btn.textContent = getLabel(); 471 btn.addEventListener('click', function () { 472 revealMore(wrap, bubble); 473 }); 474 // Insert directly before any cards container (cards are 475 // decorative payloads attached to the same wrap; the 476 // read-more pill conceptually belongs to the text bubble 477 // and should sit between them). 478 var inserted = false; 479 if (classCards && wrap.childNodes) { 480 for (var i = 0; i < wrap.childNodes.length; i++) { 481 var n = wrap.childNodes[i]; 482 if (n.nodeType === 1 && n.classList && n.classList.contains(classCards)) { 483 wrap.insertBefore(btn, n); 484 inserted = true; 485 break; 486 } 487 } 488 } 489 if (!inserted) wrap.appendChild(btn); 490 return btn; 491 } 492 493 // Measure + clamp the bubble against its current __ttfText. 494 // Caller must have mounted the wrap into the DOM first (an 495 // offsetHeight of 0 means clamping is impossible). 496 // 497 // Pagination model: each tap on "Continue reading" expands the 498 // effective threshold by one base-page worth (revealCount on 499 // the wrap, starting at 0). Without this, re-clamping after 500 // reveal would deterministically land on the SAME sentence 501 // boundary every time â findCut is pure on (text, maxH) and 502 // neither changes between renders â so the visible text would 503 // never advance and the button would be an infinite no-op. 504 // Growing the threshold by one page per reveal guarantees each 505 // tap surfaces more content; once the bubble fits the expanded 506 // threshold, the button stops appearing. 507 function clampNow(wrap, bubble) { 508 var baseMaxH = thresholdHeight(); 509 var revealCount = (wrap && wrap.__ttfRevealCount) || 0;
510 var maxH = baseMaxH * (revealCount + 1); 511 var fullText = (bubble.__ttfText != null) ? bubble.__ttfText : ''; 512 renderText(bubble, fullText); 513 setClamped(bubble, false); 514 if (bubble.offsetHeight <= maxH) { 515 wrap.__ttfPaused = false; 516 bubble.__ttfVisibleText = fullText; 517 bubble.__ttfPendingText = ''; 518 removeButton(wrap); 519 return; 520 } 521 var cut = findCut(fullText, bubble, maxH); 522 if (!cut || cut >= fullText.length) { 523 // No natural cut fits â overflow gracefully. Better than 524 // clipping mid-character. Caller's CSS / scroll handling 525 // will deal with the overflow. 526 renderText(bubble, fullText); 527 wrap.__ttfPaused = false; 528 bubble.__ttfVisibleText = fullText; 529 bubble.__ttfPendingText = ''; 530 removeButton(wrap); 531 return; 532 } 533 wrap.__ttfPaused = true; 534 bubble.__ttfVisibleText = fullText.slice(0, cut); 535 bubble.__ttfPendingText = fullText.slice(cut); 536 renderText(bubble, bubble.__ttfVisibleText); 537 setClamped(bubble, true); 538 showButton(wrap, bubble); 539 } 540 541 function revealMore(wrap, bubble) { 542 wrap.__ttfRevealCount = ((wrap && wrap.__ttfRevealCount) || 0) + 1; 543 wrap.__ttfPaused = false; 544 removeButton(wrap); 545 setClamped(bubble, false); 546 clampNow(wrap, bubble); 547 if (onAfterReveal) { 548 try { onAfterReveal(wrap, bubble); } catch (e) { /* consumer scroll bug â non-fatal */ } 549 } 550 } 551 552 return { 553 apply: function (wrap, bubble, text) { 554 if (!wrap || !bubble) return; 555 bubble.__ttfText = String(text == null ? '' : text); 556 // Fresh bubble â reset pagination so the first clamp 557 // uses one page of threshold. 558 wrap.__ttfRevealCount = 0; 559 clampNow(wrap, bubble); 560 }, 561 onDelta: function (wrap, bubble, chunk) { 562 if (!wrap || !bubble) return; 563 bubble.__ttfText = (bubble.__ttfText || '') + String(chunk == null ? '' : chunk); 564 if (wrap.__ttfPaused) { 565 // Paused â don't shove new text into the visible 566 // bubble. The whole point of the pause is "stop, let 567 // them read". The new chunk is captured in __ttfText 568 // (and the derived __ttfPendingText). On reveal, 569 // clampNow flushes from __ttfText. 570 var visible = bubble.__ttfVisibleText || ''; 571 bubble.__ttfPendingText = bubble.__ttfText.slice(visible.length); 572 return; 573 } 574 clampNow(wrap, bubble); 575 }, 576 onRetract: function (wrap, bubble, newText) { 577 if (!wrap || !bubble) return; 578 var t = String(newText == null ? '' : newText); 579 bubble.__ttfText = t; 580 bubble.__ttfVisibleText = t; 581 bubble.__ttfPendingText = ''; 582 wrap.__ttfPaused = false; 583 // Retract wipes the bubble â reset pagination to match. 584 wrap.__ttfRevealCount = 0; 585 removeButton(wrap); 586 setClamped(bubble, false); 587 // clampNow handles both the render (its first action is 588 // renderText(bubble, fullText)) and the overflow check. 589 // An empty `t` renders to an empty bubble â the consumer's 590 // renderText callback clears the container before 591 // delegating to renderMarkdown, so the old child-removal 592 // loop here is also redundant. 593 clampNow(wrap, bubble); 594 }, 595 }; 596 } 597 598 global.TtfChatCore = { 599 isMobileSafari: isMobileSafari, 600 setSendingLock: setSendingLock, 601 focusInputSafely: focusInputSafely, 602 autoResizeTextarea: autoResizeTextarea, 603 renderMarkdown: renderMarkdown, 604 renderInline: renderInline, 605 createReadMoreClamp: createReadMoreClamp, 606 }; 607})(typeof window !== 'undefined' ? window : this);
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.