Skip to content

Instantly share code, notes, and snippets.

@stow1x
Created September 18, 2025 21:13
Show Gist options
  • Select an option

  • Save stow1x/266f46dbba750304e7f5bd7f7a97df8c to your computer and use it in GitHub Desktop.

Select an option

Save stow1x/266f46dbba750304e7f5bd7f7a97df8c to your computer and use it in GitHub Desktop.
Vue.js composable based on TreeWalker and Highlight API
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Ref } from 'vue';
import { onBeforeUnmount } from 'vue';
export interface UseKeywordsHighlighterOptions {
selector?:
| string
| Element
| Node
| (Element | Node)[]
| NodeListOf<Element | ChildNode>
| null
| undefined;
source?: Ref<string | string[] | null | undefined>;
caseSensitive?: boolean;
wholeWord?: boolean;
highlightId?: string;
}
export interface UseKeywordsHighlighterReturn {
mark: (value: string | string[] | null | undefined) => void;
unmark: () => void;
isSupported: boolean;
highlightName: string;
}
const isHighlightSupported = (): boolean => {
try {
return typeof CSS !== 'undefined' && 'highlights' in CSS && !!(CSS as any).highlights;
} catch {
return false;
}
};
const normalizeContainers = (selector: UseKeywordsHighlighterOptions['selector']): Element[] => {
if (typeof document === 'undefined') {
return [];
}
if (!selector) {
return document.body ? [document.body] : [];
}
if (typeof selector === 'string') {
return Array.from(document.querySelectorAll(selector));
}
if (selector instanceof Element) {
return [selector];
}
if ((selector as Node)?.nodeType === Node.ELEMENT_NODE) {
return [selector as Element];
}
if (Array.isArray(selector)) {
return (selector as (Element | Node)[])
.map((n) => (n instanceof Element ? n : (n as Element)))
.filter(Boolean);
}
if ((selector as NodeListOf<Element | ChildNode>)?.forEach) {
return Array.from(selector as NodeListOf<Element | ChildNode>).filter(
(n): n is Element => n instanceof Element,
);
}
return document.body ? [document.body] : [];
};
const collectTextNodes = (containers: Element[]): Text[] => {
const texts: Text[] = [];
for (const root of containers) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let curr = walker.nextNode();
while (curr) {
const t = curr as Text;
if (t.data && t.data.trim().length) {
texts.push(t);
}
curr = walker.nextNode();
}
}
return texts;
};
const toTerms = (value: string | string[] | null | undefined): string[] => {
if (!value) {
return [];
}
const raw = Array.isArray(value) ? value : value.split(/\s+/g);
return raw.map((t) => t.trim()).filter(Boolean);
};
const buildMatcher = (terms: string[], caseSensitive: boolean, wholeWord: boolean) => {
if (!terms.length) {
return null as RegExp | null;
}
const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = wholeWord ? `\\b(?:${escaped.join('|')})\\b` : `(?:${escaped.join('|')})`;
return new RegExp(pattern, caseSensitive ? 'g' : 'gi');
};
export const useKeywordsHighlighter = (
options: UseKeywordsHighlighterOptions = {
},
): UseKeywordsHighlighterReturn => {
const {
selector = undefined,
caseSensitive = false,
wholeWord = false,
highlightId,
} = options;
const isSupported = isHighlightSupported();
const highlightName = highlightId || 'keywords-highlighter';
const unmark = () => {
if (!isSupported) {
return;
}
(CSS as any).highlights.delete(highlightName);
};
const mark = async (value: string | string[] | null | undefined) => {
if (!isSupported) {
return;
}
if (typeof document === 'undefined') {
return;
}
const containers = normalizeContainers(selector);
const terms = toTerms(value);
if (!terms.length) {
unmark();
return;
}
const matcher = buildMatcher(terms, caseSensitive, wholeWord);
if (!matcher) {
unmark();
return;
}
const textNodes = collectTextNodes(containers);
const ranges: Range[] = [];
for (const node of textNodes) {
const text = node.data;
matcher.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = matcher.exec(text)) !== null) {
const start = m.index;
const end = start + m[0].length;
const r = new Range();
r.setStart(node, start);
r.setEnd(node, end);
ranges.push(r);
if (matcher.lastIndex === m.index) {
matcher.lastIndex++;
}
}
}
const HighlightCtor = (globalThis as any).Highlight || (window as any)?.Highlight;
if (!HighlightCtor) {
return;
}
const highlight = new HighlightCtor(...ranges);
(CSS as any).highlights.set(highlightName, highlight);
};
onBeforeUnmount(() => {
unmark();
});
return {
mark,
unmark,
isSupported,
highlightName,
};
};
export default useKeywordsHighlighter;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment