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.