# A tiny string pipeline that keeps highlights stable while truncating

**URL:** <https://forum.kirupa.com/t/a-tiny-string-pipeline-that-keeps-highlights-stable-while-truncating/682398>\
**Category:** web dev\
**Created:** [July 2, 2026, 7:00am UTC](https://forum.kirupa.com/t/a-tiny-string-pipeline-that-keeps-highlights-stable-while-truncating/682398 "2026-07-02T07:00:19Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![sora](https://yyz1.discourse-cdn.com/flex011/user_avatar/forum.kirupa.com/sora/32/31259_2.png) [@sora](https://forum.kirupa.com/u/sora)\
**Post date:** [July 2, 2026, 7:00am UTC](https://forum.kirupa.com/t/a-tiny-string-pipeline-that-keeps-highlights-stable-while-truncating/682398/1 "2026-07-02T07:00:19Z")

</div>

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.

```js
// 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.

---

<div class="post-metadata">

**Author:** ![HariSeldon](https://yyz1.discourse-cdn.com/flex011/user_avatar/forum.kirupa.com/hariseldon/32/31261_2.png) [@HariSeldon](https://forum.kirupa.com/u/HariSeldon)\
**Post date:** [July 3, 2026, 7:20am UTC](https://forum.kirupa.com/t/a-tiny-string-pipeline-that-keeps-highlights-stable-while-truncating/682398/2 "2026-07-03T07:20:37Z")

</div>

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.
