Documentation Index
Fetch the complete documentation index at: https://mintlify.com/adelpro/quran-search-engine/llms.txt
Use this file to discover all available pages before exploring further.
The highlighting system provides character-level ranges for matched tokens without generating HTML. This keeps the library UI-agnostic while giving you complete control over rendering.
Overview
The getHighlightRanges() function computes non-overlapping highlight ranges from search results. The consumer controls rendering - no dangerouslySetInnerHTML required.
Basic usage
getHighlightRanges(text, matchedTokens, tokenTypes?)
Parameters:
text: string - The text to highlight (typically verse.uthmani)
matchedTokens: readonly string[] | undefined - Tokens matched by the search
tokenTypes?: Record<string, MatchType> - Optional map of token to match type
Returns: HighlightRange[]
import { getHighlightRanges } from 'quran-search-engine';
const ranges = getHighlightRanges(verse.uthmani, verse.matchedTokens, verse.tokenTypes);
// Example output:
// [
// { start: 12, end: 23, token: 'الله', matchType: 'exact' },
// { start: 30, end: 45, token: 'الرحمن', matchType: 'lemma' },
// ]
HighlightRange type
From src/utils/highlight.ts:4-9:
export type HighlightRange = {
start: number;
end: number;
token: string;
matchType: MatchType;
};
Fields:
start - Starting character index (inclusive)
end - Ending character index (exclusive)
token - The original query token that matched
matchType - Type of match: 'exact' | 'lemma' | 'root' | 'fuzzy' | 'none'
Ranges are non-overlapping and sorted by position. If tokens overlap, the longest match takes priority.
React rendering example
From the README, here’s how to render highlights in React:
import { getHighlightRanges, type ScoredQuranText } from 'quran-search-engine';
import type { ReactNode } from 'react';
export function Verse({ verse }: { verse: ScoredQuranText }) {
const ranges = getHighlightRanges(verse.uthmani, verse.matchedTokens, verse.tokenTypes);
if (ranges.length === 0) return <span>{verse.uthmani}</span>;
const parts: ReactNode[] = [];
let cursor = 0;
ranges.forEach((r, i) => {
if (cursor < r.start) parts.push(verse.uthmani.slice(cursor, r.start));
parts.push(
<span key={`${r.start}-${r.end}-${i}`} className={`highlight highlight-${r.matchType}`}>
{verse.uthmani.slice(r.start, r.end)}
</span>,
);
cursor = r.end;
});
if (cursor < verse.uthmani.length) parts.push(verse.uthmani.slice(cursor));
return <span>{parts}</span>;
}
Styling by match type
.highlight {
padding: 2px 4px;
border-radius: 3px;
}
.highlight-exact {
background-color: #ffeb3b;
font-weight: bold;
}
.highlight-lemma {
background-color: #b3e5fc;
}
.highlight-root {
background-color: #c8e6c9;
}
.highlight-fuzzy {
background-color: #ffe0b2;
font-style: italic;
}
How it works
Diacritic-aware regex creation
For each matched token, create a regex pattern that matches the token with optional diacritics and handles character variants.From src/utils/highlight.ts:17-36:const createDiacriticRegex = (token: string) => {
const normalizedToken = normalizeArabic(token);
const escaped = escapeRegExp(normalizedToken);
const tashkeel = '[\\u064B-\\u065F\\u0670\\u06D6-\\u06ED\\u0640]*?';
const letters = escaped.split('').map((char) => {
if (char === 'ا') return '[اأإآٱ\\u0670و]';
if (char === 'ي') return '[يى\\u06CC\\u0626]';
if (char === 'ى') return '[ىي\\u06CC\\u0ئ]';
if (char === 'ة') return '[ةهت]';
if (char === 'ه') return '[هة]';
if (char === 'ك') return '[ك\\u06AC\\u06AD\\u06AE\\u06AF\\u06B0]';
if (char === 'ء') return '[ءؤئ]';
if (char === 'و') return '[وؤ]';
return char;
});
const tokenPattern = letters.join(tashkeel);
return new RegExp(`([^\\s]*${tokenPattern}[^\\s]*)`, 'gu');
};
Find all matches
For each token, find all matching positions in the text and record their ranges with priority based on match length.for (const token of matchedTokens) {
const regex = createDiacriticRegex(token);
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const matchType = tokenTypes?.[token] ?? 'fuzzy';
matches.push({
start: match.index,
end: match.index + match[0].length,
token,
matchType: matchType === 'none' ? 'fuzzy' : matchType,
priority: match[0].length,
});
}
}
Sort by priority
Sort matches by length (longer matches first), then by position.matches.sort((a, b) => {
if (a.priority !== b.priority) return b.priority - a.priority;
return a.start - b.start;
});
Remove overlaps
Keep only non-overlapping ranges using a character occupation tracker.const finalRanges: InternalMatchRange[] = [];
const occupied = new Array(text.length).fill(false);
for (const m of matches) {
let isFree = true;
for (let i = m.start; i < m.end; i++) {
if (occupied[i]) {
isFree = false;
break;
}
}
if (!isFree) continue;
finalRanges.push(m);
for (let i = m.start; i < m.end; i++) {
occupied[i] = true;
}
}
Sort by position and return
Sort final ranges by starting position and remove internal priority field.finalRanges.sort((a, b) => a.start - b.start);
return finalRanges.map(({ priority: _priority, ...range }) => range);
Character variant handling
The regex generator handles Arabic character variants to ensure robust matching:
| Input Character | Matches |
|---|
| ا (alef) | ا أ إ آ ٱ و (with dagger alef) |
| ي (ya) | ي ى and variants |
| ة (ta marbuta) | ة ه ت |
| ه (ha) | ه ة |
| ك (kaf) | ك and Persian/Urdu variants |
| ء (hamza) | ء ؤ ئ |
| و (waw) | و ؤ |
The regex patterns allow optional diacritics between every character, so “الله” will match “ٱللَّهِ” even with full tashkeel.
Empty results
If no tokens are provided or no matches are found, the function returns an empty array:
import { getHighlightRanges } from 'quran-search-engine';
const ranges = getHighlightRanges(text, undefined);
// ranges => []
const ranges2 = getHighlightRanges(text, []);
// ranges2 => []
Always check if ranges are empty before attempting to render highlights. Rendering logic should gracefully handle empty range arrays.
Other frameworks
While the React example is shown above, the same principle applies to any framework:
Vue example
<template>
<span>
<template v-for="(part, i) in renderParts" :key="i">
<span v-if="part.isHighlight" :class="`highlight highlight-${part.matchType}`">
{{ part.text }}
</span>
<template v-else>{{ part.text }}</template>
</template>
</span>
</template>
<script setup>
import { getHighlightRanges } from 'quran-search-engine';
import { computed } from 'vue';
const props = defineProps(['verse']);
const renderParts = computed(() => {
const ranges = getHighlightRanges(
props.verse.uthmani,
props.verse.matchedTokens,
props.verse.tokenTypes
);
if (ranges.length === 0) {
return [{ text: props.verse.uthmani, isHighlight: false }];
}
const parts = [];
let cursor = 0;
ranges.forEach((r) => {
if (cursor < r.start) {
parts.push({ text: props.verse.uthmani.slice(cursor, r.start), isHighlight: false });
}
parts.push({
text: props.verse.uthmani.slice(r.start, r.end),
isHighlight: true,
matchType: r.matchType,
});
cursor = r.end;
});
if (cursor < props.verse.uthmani.length) {
parts.push({ text: props.verse.uthmani.slice(cursor), isHighlight: false });
}
return parts;
});
</script>
Vanilla JavaScript example
import { getHighlightRanges } from 'quran-search-engine';
function renderHighlightedVerse(verse) {
const ranges = getHighlightRanges(verse.uthmani, verse.matchedTokens, verse.tokenTypes);
if (ranges.length === 0) {
return document.createTextNode(verse.uthmani);
}
const container = document.createElement('span');
let cursor = 0;
ranges.forEach((r) => {
if (cursor < r.start) {
container.appendChild(document.createTextNode(verse.uthmani.slice(cursor, r.start)));
}
const highlight = document.createElement('span');
highlight.className = `highlight highlight-${r.matchType}`;
highlight.textContent = verse.uthmani.slice(r.start, r.end);
container.appendChild(highlight);
cursor = r.end;
});
if (cursor < verse.uthmani.length) {
container.appendChild(document.createTextNode(verse.uthmani.slice(cursor)));
}
return container;
}