claude-plan-viewer 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ export function useDebounce<T>(value: T, delay: number): T {
4
+ const [debouncedValue, setDebouncedValue] = useState(value);
5
+
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => {
8
+ setDebouncedValue(value);
9
+ }, delay);
10
+
11
+ return () => {
12
+ clearTimeout(timer);
13
+ };
14
+ }, [value, delay]);
15
+
16
+ return debouncedValue;
17
+ }
@@ -0,0 +1,78 @@
1
+ import { useState, useCallback } from "react";
2
+ import type { SortKey, SortDir } from "../types.ts";
3
+
4
+ // Natural default sort direction per column type
5
+ const SORT_DEFAULTS: Record<SortKey, SortDir> = {
6
+ title: "asc", // A → Z
7
+ project: "asc", // A → Z
8
+ size: "desc", // Biggest first
9
+ lines: "desc", // Biggest first
10
+ modified: "desc", // Newest first
11
+ created: "desc", // Newest first
12
+ };
13
+
14
+ interface UseFiltersReturn {
15
+ searchQuery: string;
16
+ setSearchQuery: (query: string) => void;
17
+ sortKey: SortKey;
18
+ sortDir: SortDir;
19
+ setSort: (key: SortKey, dir?: SortDir) => void;
20
+ selectedProjects: Set<string>;
21
+ toggleProject: (project: string) => void;
22
+ clearProjects: () => void;
23
+ }
24
+
25
+ export function useFilters(): UseFiltersReturn {
26
+ const [searchQuery, setSearchQuery] = useState("");
27
+ const [sortKey, setSortKey] = useState<SortKey>("modified");
28
+ const [sortDir, setSortDir] = useState<SortDir>("desc");
29
+ const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
30
+ new Set()
31
+ );
32
+
33
+ const setSort = useCallback((key: SortKey, dir?: SortDir) => {
34
+ if (dir) {
35
+ // Explicit direction provided
36
+ setSortKey(key);
37
+ setSortDir(dir);
38
+ } else {
39
+ setSortKey((prevKey) => {
40
+ if (prevKey === key) {
41
+ // Same column: toggle direction
42
+ setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
43
+ } else {
44
+ // New column: use its natural default
45
+ setSortDir(SORT_DEFAULTS[key]);
46
+ }
47
+ return key;
48
+ });
49
+ }
50
+ }, []);
51
+
52
+ const toggleProject = useCallback((project: string) => {
53
+ setSelectedProjects((prev) => {
54
+ const next = new Set(prev);
55
+ if (next.has(project)) {
56
+ next.delete(project);
57
+ } else {
58
+ next.add(project);
59
+ }
60
+ return next;
61
+ });
62
+ }, []);
63
+
64
+ const clearProjects = useCallback(() => {
65
+ setSelectedProjects(new Set());
66
+ }, []);
67
+
68
+ return {
69
+ searchQuery,
70
+ setSearchQuery,
71
+ sortKey,
72
+ sortDir,
73
+ setSort,
74
+ selectedProjects,
75
+ toggleProject,
76
+ clearProjects,
77
+ };
78
+ }
@@ -0,0 +1,114 @@
1
+ import { useEffect, useCallback } from "react";
2
+ import type { Plan } from "../types.ts";
3
+
4
+ interface UseKeyboardOptions {
5
+ plans: Plan[];
6
+ selectedPlan: Plan | null;
7
+ onSelectPlan: (plan: Plan | null) => void;
8
+ onOpenEditor: () => void;
9
+ onToggleHelp: () => void;
10
+ onToggleOverlay: () => void;
11
+ onClearSearch: () => void;
12
+ }
13
+
14
+ export function useKeyboard({
15
+ plans,
16
+ selectedPlan,
17
+ onSelectPlan,
18
+ onOpenEditor,
19
+ onToggleHelp,
20
+ onToggleOverlay,
21
+ onClearSearch,
22
+ }: UseKeyboardOptions): void {
23
+ const handleKeyDown = useCallback(
24
+ (e: KeyboardEvent) => {
25
+ // Cmd/Ctrl + K: Focus search
26
+ if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
27
+ e.preventDefault();
28
+ const search = document.getElementById("search") as HTMLInputElement;
29
+ search?.focus();
30
+ search?.select();
31
+ return;
32
+ }
33
+
34
+ // Arrow navigation
35
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
36
+ e.preventDefault();
37
+ const idx = plans.findIndex(
38
+ (p) => p.filename === selectedPlan?.filename
39
+ );
40
+ let newIdx = e.key === "ArrowDown" ? idx + 1 : idx - 1;
41
+ if (newIdx < 0) newIdx = 0;
42
+ if (newIdx >= plans.length) newIdx = plans.length - 1;
43
+ const plan = plans[newIdx];
44
+ if (plan) {
45
+ onSelectPlan(plan);
46
+ document
47
+ .querySelector(`tr[data-filename="${plan.filename}"]`)
48
+ ?.scrollIntoView({ block: "nearest" });
49
+ }
50
+ return;
51
+ }
52
+
53
+ // Enter: Open in editor
54
+ if (e.key === "Enter" && selectedPlan) {
55
+ const activeEl = document.activeElement;
56
+ if (
57
+ activeEl?.tagName !== "INPUT" &&
58
+ activeEl?.tagName !== "TEXTAREA" &&
59
+ activeEl?.tagName !== "BUTTON"
60
+ ) {
61
+ e.preventDefault();
62
+ onOpenEditor();
63
+ }
64
+ return;
65
+ }
66
+
67
+ // Escape: Clear search or close modal
68
+ if (e.key === "Escape") {
69
+ onClearSearch();
70
+ return;
71
+ }
72
+
73
+ // ?: Toggle help
74
+ if (e.key === "?" && !e.metaKey && !e.ctrlKey) {
75
+ const activeEl = document.activeElement;
76
+ if (activeEl?.tagName !== "INPUT" && activeEl?.tagName !== "TEXTAREA") {
77
+ e.preventDefault();
78
+ onToggleHelp();
79
+ }
80
+ return;
81
+ }
82
+
83
+ // F: Toggle fullscreen overlay
84
+ if (
85
+ e.key === "f" &&
86
+ selectedPlan &&
87
+ !e.metaKey &&
88
+ !e.ctrlKey &&
89
+ !e.altKey
90
+ ) {
91
+ const activeEl = document.activeElement;
92
+ if (activeEl?.tagName !== "INPUT" && activeEl?.tagName !== "TEXTAREA") {
93
+ e.preventDefault();
94
+ onToggleOverlay();
95
+ }
96
+ return;
97
+ }
98
+ },
99
+ [
100
+ plans,
101
+ selectedPlan,
102
+ onSelectPlan,
103
+ onOpenEditor,
104
+ onToggleHelp,
105
+ onToggleOverlay,
106
+ onClearSearch,
107
+ ]
108
+ );
109
+
110
+ useEffect(() => {
111
+ document.addEventListener("keydown", handleKeyDown);
112
+ return () => document.removeEventListener("keydown", handleKeyDown);
113
+ }, [handleKeyDown]);
114
+ }
@@ -0,0 +1,169 @@
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
+ import type { Plan, SortKey, SortDir } from "../types.ts";
3
+ import { fetchPlans, fetchPlanContent, refreshCache } from "../utils/api.ts";
4
+
5
+ export interface UsePlansParams {
6
+ q?: string;
7
+ sort?: SortKey;
8
+ dir?: SortDir;
9
+ projects?: string[];
10
+ }
11
+
12
+ interface UsePlansReturn {
13
+ plans: Plan[];
14
+ total: number;
15
+ loading: boolean;
16
+ refresh: () => Promise<void>;
17
+ ensureContent: (plan: Plan) => Promise<Plan>;
18
+ }
19
+
20
+ export function usePlans(
21
+ params: UsePlansParams = {},
22
+ ): UsePlansReturn {
23
+ const [allPlans, setAllPlans] = useState<Plan[]>([]);
24
+ const [loading, setLoading] = useState(true);
25
+ const abortControllerRef = useRef<AbortController | null>(null);
26
+
27
+ // Cache content by filename to persist across filter changes
28
+ const contentCache = useRef<Map<string, string>>(new Map());
29
+
30
+ const loadAllPlans = useCallback(async () => {
31
+ // Cancel previous request
32
+ if (abortControllerRef.current) {
33
+ abortControllerRef.current.abort();
34
+ }
35
+ abortControllerRef.current = new AbortController();
36
+
37
+ try {
38
+ setLoading(true);
39
+ const fetchedPlans = await fetchPlans(abortControllerRef.current.signal);
40
+
41
+ // Restore cached content
42
+ const plansWithContent = fetchedPlans.map((p) => ({
43
+ ...p,
44
+ content: contentCache.current.get(p.filename),
45
+ }));
46
+
47
+ setAllPlans(plansWithContent);
48
+ } catch (err) {
49
+ if (err instanceof Error && err.name === "AbortError") {
50
+ return;
51
+ }
52
+ console.error("Failed to load plans:", err);
53
+ } finally {
54
+ setLoading(false);
55
+ }
56
+ }, []);
57
+
58
+ // Load all plans on mount and when parameters change
59
+ useEffect(() => {
60
+ loadAllPlans();
61
+ return () => {
62
+ abortControllerRef.current?.abort();
63
+ };
64
+ }, [loadAllPlans]);
65
+
66
+ const refresh = useCallback(async () => {
67
+ contentCache.current.clear();
68
+ await refreshCache(); // Invalidate backend cache too
69
+ await loadAllPlans();
70
+ }, [loadAllPlans]);
71
+
72
+ const ensureContent = useCallback(
73
+ async (plan: Plan): Promise<Plan> => {
74
+ if (plan.content) return plan;
75
+
76
+ // Check cache
77
+ const cached = contentCache.current.get(plan.filename);
78
+ if (cached) {
79
+ const updatedPlan = { ...plan, content: cached };
80
+ setAllPlans((prev) =>
81
+ prev.map((p) => (p.filename === plan.filename ? updatedPlan : p))
82
+ );
83
+ return updatedPlan;
84
+ }
85
+
86
+ const content = await fetchPlanContent(plan.filename);
87
+ contentCache.current.set(plan.filename, content);
88
+ const updatedPlan = { ...plan, content };
89
+
90
+ // Update in state
91
+ setAllPlans((prev) =>
92
+ prev.map((p) => (p.filename === plan.filename ? updatedPlan : p))
93
+ );
94
+
95
+ return updatedPlan;
96
+ },
97
+ []
98
+ );
99
+
100
+ // Client-side filtering
101
+ const filteredPlans = useMemo(() => {
102
+ let filtered = allPlans;
103
+
104
+ if (params.q) {
105
+ const lowerQ = params.q.toLowerCase();
106
+ filtered = filtered.filter(p => {
107
+ const content = contentCache.current.get(p.filename) || "";
108
+ return (
109
+ p.title.toLowerCase().includes(lowerQ) ||
110
+ content.toLowerCase().includes(lowerQ) ||
111
+ p.filename.toLowerCase().includes(lowerQ) ||
112
+ (p.project?.toLowerCase().includes(lowerQ) ?? false)
113
+ );
114
+ });
115
+ }
116
+
117
+ if (params.projects && params.projects.length > 0) {
118
+ const projectFilterSet = new Set(params.projects);
119
+ filtered = filtered.filter(p => p.project && projectFilterSet.has(p.project));
120
+ }
121
+
122
+ return filtered;
123
+ }, [allPlans, params.q, params.projects]);
124
+
125
+ // Client-side sorting
126
+ const sortedPlans = useMemo(() => {
127
+ const { sort, dir } = params;
128
+ if (!sort) return filteredPlans;
129
+
130
+ const sorted = [...filteredPlans].sort((a, b) => {
131
+ let cmp = 0;
132
+ switch (sort) {
133
+ case "title":
134
+ cmp = a.title.localeCompare(b.title);
135
+ break;
136
+ case "project":
137
+ if (!a.project && !b.project) cmp = 0;
138
+ else if (!a.project) return 1;
139
+ else if (!b.project) return -1;
140
+ else cmp = a.project.localeCompare(b.project);
141
+ break;
142
+ case "size":
143
+ cmp = a.size - b.size;
144
+ break;
145
+ case "lines":
146
+ cmp = a.lineCount - b.lineCount;
147
+ break;
148
+ case "created":
149
+ cmp = new Date(a.created).getTime() - new Date(b.created).getTime();
150
+ break;
151
+ case "modified":
152
+ default:
153
+ cmp = new Date(a.modified).getTime() - new Date(b.modified).getTime();
154
+ break;
155
+ }
156
+ return dir === "asc" ? cmp : -cmp;
157
+ });
158
+
159
+ return sorted;
160
+ }, [filteredPlans, params.sort, params.dir]);
161
+
162
+ return {
163
+ plans: sortedPlans,
164
+ total: sortedPlans.length,
165
+ loading,
166
+ refresh,
167
+ ensureContent,
168
+ };
169
+ }
@@ -0,0 +1,21 @@
1
+ import { useState, useEffect } from "react";
2
+ import { fetchProjects } from "../utils/api.ts";
3
+
4
+ interface UseProjectsReturn {
5
+ projects: string[];
6
+ loading: boolean;
7
+ }
8
+
9
+ export function useProjects(): UseProjectsReturn {
10
+ const [projects, setProjects] = useState<string[]>([]);
11
+ const [loading, setLoading] = useState(true);
12
+
13
+ useEffect(() => {
14
+ fetchProjects()
15
+ .then(setProjects)
16
+ .catch((err) => console.error("Failed to load projects:", err))
17
+ .finally(() => setLoading(false));
18
+ }, []);
19
+
20
+ return { projects, loading };
21
+ }
@@ -0,0 +1,11 @@
1
+ import { createRoot } from "react-dom/client";
2
+ import { App } from "./App.tsx";
3
+ import "../../styles.css";
4
+
5
+ const container = document.getElementById("root");
6
+ if (!container) {
7
+ throw new Error("Root element not found");
8
+ }
9
+
10
+ const root = createRoot(container);
11
+ root.render(<App />);
@@ -0,0 +1,19 @@
1
+ export interface PlanMetadata {
2
+ filename: string;
3
+ filepath: string;
4
+ title: string;
5
+ size: number;
6
+ modified: string;
7
+ created: string;
8
+ lineCount: number;
9
+ wordCount: number;
10
+ project: string | null;
11
+ sessionId: string | null;
12
+ }
13
+
14
+ export interface Plan extends PlanMetadata {
15
+ content?: string; // Lazy-loaded on demand
16
+ }
17
+
18
+ export type SortKey = "title" | "project" | "modified" | "size" | "lines" | "created";
19
+ export type SortDir = "asc" | "desc";
@@ -0,0 +1,38 @@
1
+ import type { Plan, PlanMetadata } from "../types.ts";
2
+
3
+ export async function fetchPlans(signal?: AbortSignal): Promise<PlanMetadata[]> {
4
+ const url = new URL("/api/plans", window.location.origin);
5
+ const res = await fetch(url.toString(), { signal });
6
+ if (!res.ok) {
7
+ throw new Error(`Failed to fetch plans: ${res.statusText}`);
8
+ }
9
+ const data = await res.json();
10
+ return data.plans;
11
+ }
12
+
13
+ export async function fetchProjects(): Promise<string[]> {
14
+ const res = await fetch("/api/projects");
15
+ const data = await res.json();
16
+ return data.projects;
17
+ }
18
+
19
+ export async function fetchPlanContent(filename: string): Promise<string> {
20
+ const res = await fetch(`/api/plans/${encodeURIComponent(filename)}/content`);
21
+ if (!res.ok) {
22
+ throw new Error(`Failed to fetch plan content: ${res.statusText}`);
23
+ }
24
+ const data = await res.json();
25
+ return data.content;
26
+ }
27
+
28
+ export async function openInEditor(filepath: string): Promise<void> {
29
+ await fetch("/api/open", {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({ filepath }),
33
+ });
34
+ }
35
+
36
+ export async function refreshCache(): Promise<void> {
37
+ await fetch("/api/refresh", { method: "POST" });
38
+ }
@@ -0,0 +1,40 @@
1
+ export function formatDate(iso: string): string {
2
+ const d = new Date(iso);
3
+ const now = new Date();
4
+ const diff = now.getTime() - d.getTime();
5
+
6
+ if (diff < 60000) return "just now";
7
+ if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
8
+ if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago";
9
+ if (diff < 604800000) return Math.floor(diff / 86400000) + "d ago";
10
+
11
+ // Show year if not current year
12
+ if (d.getFullYear() !== now.getFullYear()) {
13
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
14
+ }
15
+
16
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
17
+ }
18
+
19
+ export function formatFullDate(iso: string): string {
20
+ return new Date(iso).toLocaleDateString("en-US", {
21
+ year: "numeric",
22
+ month: "short",
23
+ day: "numeric",
24
+ hour: "2-digit",
25
+ minute: "2-digit",
26
+ });
27
+ }
28
+
29
+ export function formatDateISO(iso: string): string {
30
+ const d = new Date(iso);
31
+ const year = d.getFullYear();
32
+ const month = String(d.getMonth() + 1).padStart(2, "0");
33
+ const day = String(d.getDate()).padStart(2, "0");
34
+ return `${year}-${month}-${day}`;
35
+ }
36
+
37
+ export function formatSize(bytes: number): string {
38
+ if (bytes < 1024) return bytes + " B";
39
+ return (bytes / 1024).toFixed(1) + " KB";
40
+ }
@@ -0,0 +1,15 @@
1
+ export * from "./formatters.ts";
2
+ export * from "./strings.ts";
3
+ export * from "./markdown.ts";
4
+ export * from "./api.ts";
5
+
6
+ export function debounce<Args extends unknown[]>(
7
+ fn: (...args: Args) => void,
8
+ ms: number
9
+ ): (...args: Args) => void {
10
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
11
+ return (...args: Args) => {
12
+ if (timeoutId) clearTimeout(timeoutId);
13
+ timeoutId = setTimeout(() => fn(...args), ms);
14
+ };
15
+ }
@@ -0,0 +1,123 @@
1
+ import { escapeHtml } from "./strings.ts";
2
+
3
+ // Prism.js is loaded from CDN
4
+ declare const Prism: {
5
+ highlight: (code: string, grammar: unknown, language: string) => string;
6
+ languages: Record<string, unknown>;
7
+ };
8
+
9
+ export function renderMarkdown(content: string): string {
10
+ // First, extract and process code blocks before escaping
11
+ const codeBlocks: string[] = [];
12
+ let processed = content.replace(
13
+ /```(\w*)\n([\s\S]*?)```/g,
14
+ (_, lang, code) => {
15
+ const language = lang || "plaintext";
16
+ const grammar = Prism.languages[language] || Prism.languages.plaintext;
17
+ let highlighted: string;
18
+ try {
19
+ highlighted = grammar
20
+ ? Prism.highlight(code, grammar, language)
21
+ : escapeHtml(code);
22
+ } catch {
23
+ highlighted = escapeHtml(code);
24
+ }
25
+ const block = `<pre class="language-${language}"><code class="language-${language}">${highlighted}</code></pre>`;
26
+ codeBlocks.push(block);
27
+ return `\x00CODE_BLOCK_${codeBlocks.length - 1}\x00`;
28
+ }
29
+ );
30
+
31
+ // Now escape the rest
32
+ let html = escapeHtml(processed);
33
+
34
+ // Restore code blocks
35
+ html = html.replace(/\x00CODE_BLOCK_(\d+)\x00/g, (_, idx) => codeBlocks[parseInt(idx)]);
36
+
37
+ // Inline code
38
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
39
+
40
+ // Headers
41
+ html = html.replace(/^#### (.+)$/gm, "<h4>$1</h4>");
42
+ html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
43
+ html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
44
+ html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
45
+
46
+ // Bold and italic
47
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
48
+ html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
49
+
50
+ // Blockquotes
51
+ html = html.replace(/^&gt; (.+)$/gm, "<blockquote><p>$1</p></blockquote>");
52
+
53
+ // Horizontal rules
54
+ html = html.replace(/^---$/gm, "<hr>");
55
+
56
+ // Links
57
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
58
+
59
+ // Tables
60
+ html = html.replace(
61
+ /\|(.+)\|\n\|[-| ]+\|\n((?:\|.+\|\n?)+)/g,
62
+ (_, header, body) => {
63
+ const headers = header
64
+ .split("|")
65
+ .filter((c: string) => c.trim())
66
+ .map((c: string) => `<th>${c.trim()}</th>`)
67
+ .join("");
68
+ const rows = body
69
+ .trim()
70
+ .split("\n")
71
+ .map((row: string) => {
72
+ const cells = row
73
+ .split("|")
74
+ .filter((c: string) => c.trim())
75
+ .map((c: string) => `<td>${c.trim()}</td>`)
76
+ .join("");
77
+ return `<tr>${cells}</tr>`;
78
+ })
79
+ .join("");
80
+ return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
81
+ }
82
+ );
83
+
84
+ // Checkbox lists
85
+ html = html.replace(
86
+ /^- \[x\] (.+)$/gm,
87
+ '<li><input type="checkbox" checked disabled> $1</li>'
88
+ );
89
+ html = html.replace(
90
+ /^- \[ \] (.+)$/gm,
91
+ '<li><input type="checkbox" disabled> $1</li>'
92
+ );
93
+
94
+ // Regular lists
95
+ html = html.replace(/^- (.+)$/gm, "<li>$1</li>");
96
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, "<ul>$&</ul>");
97
+
98
+ // Numbered lists
99
+ html = html.replace(/^\d+\. (.+)$/gm, "<li>$1</li>");
100
+
101
+ // Paragraphs - wrap remaining text blocks
102
+ const blocks = html.split(/\n\n+/);
103
+ html = blocks
104
+ .map((block) => {
105
+ const trimmed = block.trim();
106
+ if (!trimmed) return "";
107
+ if (
108
+ trimmed.startsWith("<h") ||
109
+ trimmed.startsWith("<ul") ||
110
+ trimmed.startsWith("<ol") ||
111
+ trimmed.startsWith("<pre") ||
112
+ trimmed.startsWith("<hr") ||
113
+ trimmed.startsWith("<blockquote") ||
114
+ trimmed.startsWith("<table")
115
+ ) {
116
+ return trimmed;
117
+ }
118
+ return "<p>" + trimmed.replace(/\n/g, "<br>") + "</p>";
119
+ })
120
+ .join("\n");
121
+
122
+ return html;
123
+ }
@@ -0,0 +1,18 @@
1
+ export function escapeHtml(str: string): string {
2
+ return str
3
+ .replace(/&/g, "&amp;")
4
+ .replace(/</g, "&lt;")
5
+ .replace(/>/g, "&gt;")
6
+ .replace(/"/g, "&quot;");
7
+ }
8
+
9
+ export function escapeRegex(str: string): string {
10
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11
+ }
12
+
13
+ export function highlightText(text: string, query: string): string {
14
+ if (!query) return escapeHtml(text);
15
+ const escaped = escapeHtml(text);
16
+ const regex = new RegExp(`(${escapeRegex(query)})`, "gi");
17
+ return escaped.replace(regex, "<mark>$1</mark>");
18
+ }