{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "highlight-match",
  "type": "registry:ui",
  "title": "Highlight Match",
  "description": "Marks what matched in search results, accent-blind, with fuzzy mode and snippets.",
  "categories": [
    "command"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/highlight-match.tsx",
      "content": "import { Fragment, useMemo } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/* ------------------------------------------------------------------ */\n/* Folding                                                             */\n/* ------------------------------------------------------------------ */\n\n// Letters that don't decompose under NFD but that people type without the stroke.\nconst FOLD: Record<string, string> = { ß: \"ss\", æ: \"ae\", œ: \"oe\", ø: \"o\", đ: \"d\", ð: \"d\", ł: \"l\", ı: \"i\", þ: \"th\", ŋ: \"n\", ħ: \"h\" };\n\nconst foldChar = (cp: string) => {\n  let out = \"\";\n  for (const c of cp.toLowerCase().normalize(\"NFD\").replace(/\\p{M}/gu, \"\")) out += FOLD[c] ?? c;\n  return out;\n};\n\n/** Lowercases, strips accents and folds ligatures, so \"Łódź\" and \"lodz\" compare equal. */\nexport function fold(text: string) {\n  let out = \"\";\n  for (const cp of text) out += foldChar(cp);\n  return out;\n}\n\ntype Folded = { norm: string; start: number[]; end: number[] };\n\n// The folded text plus, for every folded character, where its source code point sits in the\n// original. That's what lets a match found in \"lodz\" be drawn over \"Łódź\" without drifting.\nfunction foldWithMap(text: string): Folded {\n  let norm = \"\";\n  const start: number[] = [];\n  const end: number[] = [];\n  let i = 0;\n  for (const cp of text) {\n    const f = foldChar(cp);\n    for (const c of f) {\n      norm += c;\n      start.push(i);\n      end.push(i + cp.length);\n    }\n    // A bare combining mark folds to nothing; stretch the previous character over it so \"é\"\n    // written as e + ◌́ is never split down the middle.\n    if (!f && end.length) end[end.length - 1] = i + cp.length;\n    i += cp.length;\n  }\n  return { norm, start, end };\n}\n\n/* ------------------------------------------------------------------ */\n/* Matching                                                            */\n/* ------------------------------------------------------------------ */\n\nexport type MatchMode = \"words\" | \"substring\" | \"fuzzy\";\nexport type MatchRange = readonly [start: number, end: number];\nexport type Match = { ranges: MatchRange[]; score: number };\n\nconst isBoundary = (s: string, j: number) => j === 0 || /[\\s\\-_./:@#([]/.test(s[j - 1]);\n\nfunction toRanges(f: Folded, spans: [number, number][]): MatchRange[] {\n  // Spans are in folded characters; map them back and merge anything touching.\n  const mapped = spans\n    .map(([a, b]) => [f.start[a], f.end[b - 1]] as [number, number])\n    .sort((x, y) => x[0] - y[0]);\n  const out: [number, number][] = [];\n  for (const r of mapped) {\n    const last = out[out.length - 1];\n    if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]);\n    else out.push([r[0], r[1]]);\n  }\n  return out;\n}\n\nfunction occurrences(hay: string, needle: string) {\n  const spans: [number, number][] = [];\n  if (!needle) return spans;\n  for (let at = hay.indexOf(needle); at !== -1; at = hay.indexOf(needle, at + needle.length)) spans.push([at, at + needle.length]);\n  return spans;\n}\n\n// Best-scoring subsequence alignment: rewards consecutive runs and letters that start a word,\n// charges for gaps. Linear gap cost keeps it O(query × text).\nfunction fuzzy(f: Folded, q: string): Match | null {\n  const t = f.norm;\n  const n = t.length;\n  const m = q.length;\n  if (!m || m > n || n > 1000) return null;\n  const NEG = -1e9;\n  const GAP = 0.15;\n  const score: Float64Array[] = [];\n  const from: Int32Array[] = [];\n  for (let i = 0; i < m; i++) {\n    const row = new Float64Array(n).fill(NEG);\n    const back = new Int32Array(n).fill(-1);\n    let best = NEG;\n    let bestAt = -1;\n    for (let j = 0; j < n; j++) {\n      // Running best over every earlier k of prev[k] + GAP * k, so the gap cost is one subtraction.\n      if (i > 0 && j > 0) {\n        const cand = score[i - 1][j - 1] + GAP * (j - 1);\n        if (cand > best) {\n          best = cand;\n          bestAt = j - 1;\n        }\n      }\n      if (t[j] !== q[i]) continue;\n      const bonus = 1 + (isBoundary(t, j) ? 1.5 : 0) + (j === 0 ? 0.5 : 0);\n      if (i === 0) {\n        row[j] = bonus - j * 0.01;\n        continue;\n      }\n      const run = score[i - 1][j - 1];\n      const viaRun = j > 0 && run > NEG ? run + bonus + 2 : NEG;\n      const viaGap = bestAt >= 0 && best > NEG / 2 ? best - GAP * (j - 1) - GAP + bonus : NEG;\n      if (viaRun >= viaGap && viaRun > NEG) {\n        row[j] = viaRun;\n        back[j] = j - 1;\n      } else if (viaGap > NEG) {\n        row[j] = viaGap;\n        back[j] = bestAt;\n      }\n    }\n    score.push(row);\n    from.push(back);\n  }\n  let end = -1;\n  let top = NEG;\n  for (let j = 0; j < n; j++) if (score[m - 1][j] > top) (top = score[m - 1][j]), (end = j);\n  if (end < 0 || top <= NEG / 2) return null;\n  const hits: number[] = [];\n  for (let i = m - 1, j = end; i >= 0; i--) {\n    hits.push(j);\n    j = from[i][j];\n  }\n  hits.reverse();\n  const spans: [number, number][] = [];\n  for (const h of hits) {\n    const last = spans[spans.length - 1];\n    if (last && last[1] === h) last[1] = h + 1;\n    else spans.push([h, h + 1]);\n  }\n  return { ranges: toRanges(f, spans), score: top / m };\n}\n\nconst terms = (query: string) => [...new Set(fold(query).split(/\\s+/).filter(Boolean))];\n\nfunction literal(f: Folded, needles: string[], strict: boolean): Match | null {\n  const spans: [number, number][] = [];\n  let score = 0;\n  for (const needle of needles) {\n    const hits = occurrences(f.norm, needle);\n    if (!hits.length) {\n      if (strict) return null;\n      continue;\n    }\n    const first = hits[0][0];\n    // Earlier is better, the start of a word is better still, and the whole text is best.\n    score += 1 + (isBoundary(f.norm, first) ? 1 : 0) + (first === 0 ? 0.5 : 0) + 1 / (1 + first / 12);\n    if (needle.length === f.norm.length) score += 2;\n    spans.push(...hits);\n  }\n  if (!spans.length) return null;\n  return { ranges: toRanges(f, spans), score: score / needles.length };\n}\n\n/**\n * Where `query` matches `text`, case and accent insensitive, or null when it doesn't.\n * - `words` (default): every word of the query must appear, in any order.\n * - `substring`: the query as typed, spaces and all.\n * - `fuzzy`: the query's letters in order, gaps allowed, favouring runs and word starts.\n * `score` is higher for better matches; compare it only within one mode.\n */\nexport function findMatches(text: string, query: string, mode: MatchMode = \"words\"): Match | null {\n  const f = foldWithMap(text);\n  if (mode === \"fuzzy\") return fuzzy(f, fold(query).replace(/\\s+/g, \"\"));\n  if (mode === \"substring\") {\n    const q = fold(query).replace(/\\s+/g, \" \").trim();\n    return q ? literal(f, [q], true) : null;\n  }\n  const t = terms(query);\n  return t.length ? literal(f, t, true) : null;\n}\n\n// Rendering is forgiving where filtering is strict: a snippet shows every word it does contain.\nfunction rangesFor(text: string, query: string | string[], mode: MatchMode): MatchRange[] {\n  if (Array.isArray(query)) return query.flatMap((q) => rangesFor(text, q, mode)).sort((a, b) => a[0] - b[0]);\n  if (!query.trim()) return [];\n  const f = foldWithMap(text);\n  if (mode === \"fuzzy\") return fuzzy(f, fold(query).replace(/\\s+/g, \"\"))?.ranges ?? [];\n  const needles = mode === \"substring\" ? [fold(query).replace(/\\s+/g, \" \").trim()] : terms(query);\n  return literal(f, needles, false)?.ranges ?? [];\n}\n\n/* ------------------------------------------------------------------ */\n/* Excerpt                                                             */\n/* ------------------------------------------------------------------ */\n\n// A window of about `size` characters that opens a few words before the first match, snapped\n// to whole words, so long body text becomes a snippet with the match near its start. The lead is\n// kept short on purpose: a snippet is usually clamped to one line, and a match pushed past the\n// clamp is a match nobody sees.\nfunction snippet(text: string, ranges: MatchRange[], size: number) {\n  if (text.length <= size) return { from: 0, to: text.length };\n  const anchor = ranges[0]?.[0] ?? 0;\n  let from = Math.max(0, anchor - Math.min(24, Math.round(size * 0.25)));\n  let to = Math.min(text.length, from + size);\n  if (from > 0) {\n    const space = text.indexOf(\" \", from);\n    if (space !== -1 && space < anchor) from = space + 1;\n  }\n  if (to < text.length) {\n    const space = text.lastIndexOf(\" \", to);\n    if (space > from && space > (ranges[0]?.[1] ?? 0)) to = space;\n  }\n  return { from, to };\n}\n\n/* ------------------------------------------------------------------ */\n/* Component                                                           */\n/* ------------------------------------------------------------------ */\n\nexport type HighlightMatchProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  text: string;\n  /** What to mark. An array marks several queries at once, e.g. one per active search term. */\n  query: string | string[];\n  mode?: MatchMode;\n  /** Precomputed ranges (from findMatches) instead of a query, so a list doesn't match twice. */\n  ranges?: readonly MatchRange[];\n  /** \"fill\" washes the match like find-in-page; \"text\" lifts it to full contrast and medium weight. */\n  variant?: \"fill\" | \"text\";\n  /** Show a snippet of about this many characters around the first match, with ellipses. */\n  excerpt?: number;\n  markClassName?: string;\n};\n\nexport function HighlightMatch({ text, query, mode = \"words\", ranges: given, variant = \"fill\", excerpt, markClassName, className, ...rest }: HighlightMatchProps) {\n  const ranges = useMemo(() => (given ? [...given] : rangesFor(text, query, mode)), [given, text, query, mode]);\n  const { from, to } = excerpt ? snippet(text, ranges, excerpt) : { from: 0, to: text.length };\n\n  const parts: React.ReactNode[] = [];\n  let at = from;\n  for (const [a, b] of ranges) {\n    const s = Math.max(a, from, at);\n    const e = Math.min(b, to);\n    if (e <= s) continue;\n    if (s > at) parts.push(<Fragment key={`t${at}`}>{text.slice(at, s)}</Fragment>);\n    parts.push(\n      // Keyed by where the match starts: a match that grows as you type stays the same element\n      // and simply widens, while a match that is new washes in from nothing.\n      <mark\n        key={`m${a}`}\n        data-match=\"\"\n        data-variant={variant}\n        className={cn(\n                    variant === \"fill\"\n            ? \"-mx-px box-decoration-clone rounded-[3px] bg-fg/[0.13] px-px text-fg transition-[background-color] duration-150 ease-out starting:bg-transparent\"\n            : \"bg-transparent font-medium text-fg transition-[color] duration-150 ease-out starting:text-inherit\",\n          \"motion-reduce:transition-none\",\n          markClassName,\n        )}\n      >\n        {text.slice(s, e)}\n      </mark>,\n    );\n    at = e;\n  }\n  if (at < to) parts.push(<Fragment key={`t${at}`}>{text.slice(at, to)}</Fragment>);\n\n  return (\n    <span className={className} {...rest}>\n      {from > 0 && <span aria-hidden>…</span>}\n      {parts}\n      {to < text.length && <span aria-hidden>…</span>}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/highlight-match"
}
