A tiny string pipeline that keeps highlights stable while truncating

Hey everyone, I’m wiring up a search results list and I’m trying to keep my string formatting predictable without mutating the same object in three different places. The failure mode I kept hitting was highlight ranges drifting after truncation, plus it’s easy to accidentally reuse mutated state between rows.

// Pure-ish pipeline: compute once, render many
export function formatSnippet(text, query, max = 140) {
  const q = query.trim();
  if (!q) return { head: text.slice(0, max), tail: text.length > max, hits: [] };

  const lower = text.toLowerCase();
  const ql = q.toLowerCase();
  const hits = [];

  for (let i = 0; (i = lower.indexOf(ql, i)) !== -1; i += ql.length) {
    hits.push([i, i + ql.length]);
  }

  // pick a window around first hit, but keep original indices for stable highlights
  const [s] = hits[0] ?? [0];
  const start = Math.max(0, s - Math.floor(max / 3));
  const end = Math.min(text.length, start + max);

  return {
    head: text.slice(start, end),
    offset: start,
    tail: end < text.length,
    hits // still in original coordinates
  };
}

export function toParts({ head, offset = 0, hits }) {
  // convert original hit ranges to local ranges without mutating hits
  const local = hits
    .map(([a, b]) => [a - offset, b - offset])
    .filter(([a, b]) => b > 0 && a < head.length);

  const parts = [];
  let cursor = 0;
  for (const [a, b] of local) {
    if (a > cursor) parts.push({ t: head.slice(cursor, a), hit: false });
    parts.push({ t: head.slice(Math.max(0, a), Math.min(head.length, b)), hit: true });
    cursor = Math.min(head.length, b);
  }
  if (cursor < head.length) parts.push({ t: head.slice(cursor), hit: false });
  return parts;
}

Neat part for me is the mutation strategy: I keep the “truth” (hit ranges) in original coordinates and only derive local ranges at render time, so truncation and wrapping don’t quietly corrupt shared state.

Keeping hits in original coordinates and carrying an offset is the right instinct here, because it stops “row A mutated row B” bugs cold. The one detail I’d watch is Unicode: toLowerCase() and slice() are both code-unit based, so anything with surrogate pairs or case-folding quirks (Turkish İ/ı, German ß, emoji) can make your hit ranges feel “wrong” even when your math is correct. If you expect non-ASCII queries, do you want “visual character” correctness (grapheme clusters), or is code-unit indexing good enough for your UI? That decision tends to leak into every highlight/truncate pipeline sooner than you’d like.