PageSourceSearch

https://www.ticketfairy.com/resources/js/chat-widget-core.js?v=1

js ticketfairy.com collected 2026-09-24 09:10:27 UTC 23,113 bytes, 607 lines download raw bytes

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.