Implement JSON search through fuse.js + web worker + react hook
This commit is contained in:
+2
-1
@@ -10,4 +10,5 @@ node_modules
|
|||||||
/dist
|
/dist
|
||||||
.mf
|
.mf
|
||||||
/meta.json
|
/meta.json
|
||||||
/stats.html
|
/stats.html
|
||||||
|
public/entry.worker.js
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useJsonSearchApi, useJsonSearchState } from "~/hooks/useJsonSearch";
|
||||||
|
|
||||||
|
export function SearchPalette() {
|
||||||
|
const searchState = useJsonSearchState();
|
||||||
|
const searchApi = useJsonSearchApi();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label>Search json</label>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchState.query ?? ""}
|
||||||
|
onChange={(e) => searchApi.search(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{searchState.results?.map((result) => (
|
||||||
|
<li key={result.item.path}>
|
||||||
|
[{result.item.path}] {result.item.formattedValue}
|
||||||
|
{" - "}
|
||||||
|
{result.item.rawValue}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/// <reference lib="WebWorker" />
|
||||||
|
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import { createSearchIndex, JsonSearchEntry } from "./utilities/search";
|
||||||
|
|
||||||
|
type SearchWorker = {
|
||||||
|
entries?: Array<JsonSearchEntry>;
|
||||||
|
index?: Fuse.FuseIndex<JsonSearchEntry>;
|
||||||
|
fuse?: Fuse<JsonSearchEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type {};
|
||||||
|
declare let self: DedicatedWorkerGlobalScope & SearchWorker;
|
||||||
|
|
||||||
|
type InitializeIndexEvent = {
|
||||||
|
type: "initialize-index";
|
||||||
|
payload: { json: unknown; fuseOptions: Fuse.IFuseOptions<JsonSearchEntry> };
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchEvent = {
|
||||||
|
type: "search";
|
||||||
|
payload: { query: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchWorkerEvent = InitializeIndexEvent | SearchEvent;
|
||||||
|
|
||||||
|
self.onmessage = (e: MessageEvent<SearchWorkerEvent>) => {
|
||||||
|
const { type, payload } = e.data;
|
||||||
|
|
||||||
|
console.group(`SearchWorker: ${type}`);
|
||||||
|
console.log(payload);
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "initialize-index": {
|
||||||
|
const { json, fuseOptions } = payload;
|
||||||
|
|
||||||
|
const [index, entries] = createSearchIndex(json);
|
||||||
|
|
||||||
|
self.entries = entries;
|
||||||
|
self.index = index;
|
||||||
|
self.fuse = new Fuse(entries, fuseOptions, index);
|
||||||
|
|
||||||
|
self.postMessage({ type: "index-initialized" });
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "search": {
|
||||||
|
const { query } = payload;
|
||||||
|
|
||||||
|
if (!self.fuse) {
|
||||||
|
throw new Error("Search index not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = self.fuse.search(query);
|
||||||
|
|
||||||
|
self.postMessage({ type: "search-results", payload: { results } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useJson } from "./useJson";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import { JsonSearchEntry } from "~/utilities/search";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type InitializeIndexEvent = {
|
||||||
|
type: "initialize-index";
|
||||||
|
payload: { json: unknown; fuseOptions: Fuse.IFuseOptions<JsonSearchEntry> };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchEvent = {
|
||||||
|
type: "search";
|
||||||
|
payload: { query: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchSendWorkerEvent = InitializeIndexEvent | SearchEvent;
|
||||||
|
|
||||||
|
export type IndexInitializedEvent = {
|
||||||
|
type: "index-initialized";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchResultsEvent = {
|
||||||
|
type: "search-results";
|
||||||
|
payload: { results: Fuse.FuseResult<JsonSearchEntry>[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchReceiveWorkerEvent =
|
||||||
|
| IndexInitializedEvent
|
||||||
|
| SearchResultsEvent;
|
||||||
|
|
||||||
|
export type JsonSearchApi = {
|
||||||
|
search: (query: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const JsonSearchStateContext = createContext<JsonSearchState>(
|
||||||
|
{} as JsonSearchState
|
||||||
|
);
|
||||||
|
|
||||||
|
const JsonSearchApiContext = createContext<JsonSearchApi>({} as JsonSearchApi);
|
||||||
|
|
||||||
|
export type JsonSearchState = {
|
||||||
|
status: "initializing" | "idle" | "searching";
|
||||||
|
query?: string;
|
||||||
|
results?: Fuse.FuseResult<JsonSearchEntry>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchAction = {
|
||||||
|
type: "search";
|
||||||
|
payload: { query: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonSearchAction = SearchReceiveWorkerEvent | SearchAction;
|
||||||
|
|
||||||
|
function reducer(
|
||||||
|
state: JsonSearchState,
|
||||||
|
action: JsonSearchAction
|
||||||
|
): JsonSearchState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "index-initialized":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
status: "idle",
|
||||||
|
results: undefined,
|
||||||
|
};
|
||||||
|
case "search-results":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
status: "idle",
|
||||||
|
results: action.payload.results,
|
||||||
|
};
|
||||||
|
case "search":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
status: "searching",
|
||||||
|
query: action.payload.query,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonSearchProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [json] = useJson();
|
||||||
|
|
||||||
|
const [state, dispatch] = useReducer<
|
||||||
|
React.Reducer<JsonSearchState, JsonSearchAction>
|
||||||
|
>(reducer, { status: "initializing" });
|
||||||
|
|
||||||
|
const search = useCallback(
|
||||||
|
(query: string) => {
|
||||||
|
dispatch({ type: "search", payload: { query } });
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWorkerMessage = useCallback(
|
||||||
|
(e: MessageEvent<SearchReceiveWorkerEvent>) => dispatch(e.data),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const workerRef = useRef<Worker | null>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || typeof window.Worker === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = new Worker("/entry.worker.js");
|
||||||
|
worker.onmessage = handleWorkerMessage;
|
||||||
|
|
||||||
|
workerRef.current = worker;
|
||||||
|
|
||||||
|
workerRef.current.postMessage({
|
||||||
|
type: "initialize-index",
|
||||||
|
payload: {
|
||||||
|
json,
|
||||||
|
fuseOptions: {
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
minMatchCharLength: 1,
|
||||||
|
isCaseSensitive: false,
|
||||||
|
threshold: 0.6,
|
||||||
|
distance: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [json, workerRef.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status !== "searching") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
workerRef.current?.postMessage({
|
||||||
|
type: "search",
|
||||||
|
payload: { query: state.query },
|
||||||
|
});
|
||||||
|
}, [state.status, workerRef.current]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JsonSearchStateContext.Provider value={state}>
|
||||||
|
<JsonSearchApiContext.Provider value={{ search }}>
|
||||||
|
{children}
|
||||||
|
</JsonSearchApiContext.Provider>
|
||||||
|
</JsonSearchStateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJsonSearchState(): JsonSearchState {
|
||||||
|
return useContext(JsonSearchStateContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJsonSearchApi(): JsonSearchApi {
|
||||||
|
return useContext(JsonSearchApiContext);
|
||||||
|
}
|
||||||
+27
-24
@@ -21,6 +21,7 @@ import { JsonSchemaProvider } from "~/hooks/useJsonSchema";
|
|||||||
import { JsonView } from "~/components/JsonView";
|
import { JsonView } from "~/components/JsonView";
|
||||||
import safeFetch from "~/utilities/safeFetch";
|
import safeFetch from "~/utilities/safeFetch";
|
||||||
import { JsonTreeViewProvider } from "~/hooks/useJsonTree";
|
import { JsonTreeViewProvider } from "~/hooks/useJsonTree";
|
||||||
|
import { JsonSearchProvider } from "~/hooks/useJsonSearch";
|
||||||
|
|
||||||
export const loader: LoaderFunction = async ({ params, request }) => {
|
export const loader: LoaderFunction = async ({ params, request }) => {
|
||||||
invariant(params.id, "expected params.id");
|
invariant(params.id, "expected params.id");
|
||||||
@@ -114,34 +115,36 @@ export default function JsonDocumentRoute() {
|
|||||||
<JsonProvider initialJson={loaderData.json}>
|
<JsonProvider initialJson={loaderData.json}>
|
||||||
<JsonSchemaProvider>
|
<JsonSchemaProvider>
|
||||||
<JsonColumnViewProvider>
|
<JsonColumnViewProvider>
|
||||||
<JsonTreeViewProvider overscan={25}>
|
<JsonSearchProvider>
|
||||||
<div>
|
<JsonTreeViewProvider overscan={25}>
|
||||||
<div className="h-screen flex flex-col">
|
<div>
|
||||||
<Header />
|
<div className="h-screen flex flex-col">
|
||||||
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
|
<Header />
|
||||||
<div className="main-container flex justify-items-stretch h-full">
|
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
|
||||||
<SideBar />
|
<div className="main-container flex justify-items-stretch h-full">
|
||||||
<JsonView>
|
<SideBar />
|
||||||
<Outlet />
|
<JsonView>
|
||||||
</JsonView>
|
<Outlet />
|
||||||
|
</JsonView>
|
||||||
|
|
||||||
<Resizable
|
<Resizable
|
||||||
isHorizontal={true}
|
isHorizontal={true}
|
||||||
initialSize={500}
|
initialSize={500}
|
||||||
minimumSize={280}
|
minimumSize={280}
|
||||||
maximumSize={900}
|
maximumSize={900}
|
||||||
>
|
>
|
||||||
<div className="info-panel flex-grow h-full">
|
<div className="info-panel flex-grow h-full">
|
||||||
<InfoPanel />
|
<InfoPanel />
|
||||||
</div>
|
</div>
|
||||||
</Resizable>
|
</Resizable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Footer></Footer>
|
<Footer></Footer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</JsonTreeViewProvider>
|
||||||
</JsonTreeViewProvider>
|
</JsonSearchProvider>
|
||||||
</JsonColumnViewProvider>
|
</JsonColumnViewProvider>
|
||||||
</JsonSchemaProvider>
|
</JsonSchemaProvider>
|
||||||
</JsonProvider>
|
</JsonProvider>
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export function useColumnView({
|
|||||||
selectedNodes,
|
selectedNodes,
|
||||||
highlightedNodeId,
|
highlightedNodeId,
|
||||||
highlightedPath,
|
highlightedPath,
|
||||||
columns,
|
columns: columns ?? [],
|
||||||
getColumnViewProps,
|
getColumnViewProps,
|
||||||
canGoBack,
|
canGoBack,
|
||||||
canGoForward,
|
canGoForward,
|
||||||
|
|||||||
@@ -26,9 +26,20 @@ export function formatRawValue(type: JSONValueType): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatValue(type: JSONValueType): string | undefined {
|
export type FormatValueOptions = {
|
||||||
|
leafNodesOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatValue(
|
||||||
|
type: JSONValueType,
|
||||||
|
options?: FormatValueOptions
|
||||||
|
): string | undefined {
|
||||||
switch (type.name) {
|
switch (type.name) {
|
||||||
case "array": {
|
case "array": {
|
||||||
|
if (options?.leafNodesOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type.value.length == 0) {
|
if (type.value.length == 0) {
|
||||||
return formatRawValue(type);
|
return formatRawValue(type);
|
||||||
} else if (type.value.length === 1) {
|
} else if (type.value.length === 1) {
|
||||||
@@ -38,6 +49,10 @@ export function formatValue(type: JSONValueType): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "object": {
|
case "object": {
|
||||||
|
if (options?.leafNodesOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(type.value).length == 0) {
|
if (Object.keys(type.value).length == 0) {
|
||||||
return formatRawValue(type);
|
return formatRawValue(type);
|
||||||
} else if (Object.keys(type.value).length === 1) {
|
} else if (Object.keys(type.value).length === 1) {
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ import {
|
|||||||
} from "@heroicons/react/outline";
|
} from "@heroicons/react/outline";
|
||||||
import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
|
import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
|
||||||
import { JSONHeroPath, PathComponent } from "@jsonhero/path";
|
import { JSONHeroPath, PathComponent } from "@jsonhero/path";
|
||||||
import { ArrayIcon } from "~/components/Icons/ArrayIcon";
|
|
||||||
import { ObjectIcon } from "~/components/Icons/ObjectIcon";
|
|
||||||
import { StringIcon } from "~/components/Icons/StringIcon";
|
import { StringIcon } from "~/components/Icons/StringIcon";
|
||||||
import { ColumnViewNode, IconComponent } from "~/useColumnView";
|
import { ColumnViewNode, IconComponent } from "~/useColumnView";
|
||||||
import { formatValue } from "./formatter";
|
import { formatValue } from "./formatter";
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { JSONValueType, inferType } from "@jsonhero/json-infer-types";
|
||||||
|
import { JSONHeroPath } from "@jsonhero/path";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import { formatValue } from "./formatter";
|
||||||
|
|
||||||
|
export interface JsonSearchEntry {
|
||||||
|
path: string;
|
||||||
|
rawValue?: string;
|
||||||
|
formattedValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSearchIndex(
|
||||||
|
json: unknown
|
||||||
|
): [Fuse.FuseIndex<JsonSearchEntry>, Array<JsonSearchEntry>] {
|
||||||
|
const entries = createSearchEntries(json);
|
||||||
|
|
||||||
|
const index = Fuse.createIndex<JsonSearchEntry>(
|
||||||
|
["path", "rawValue", "formattedValue"],
|
||||||
|
entries
|
||||||
|
);
|
||||||
|
|
||||||
|
return [index, entries];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSearchEntries(json: unknown): Array<JsonSearchEntry> {
|
||||||
|
return createSearchEntryChildren(inferType(json), new JSONHeroPath("$"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSearchEntryChildren(
|
||||||
|
info: JSONValueType,
|
||||||
|
path: JSONHeroPath
|
||||||
|
): Array<JsonSearchEntry> {
|
||||||
|
if (info.name === "array" && info.value) {
|
||||||
|
return info.value.flatMap((value, index) => {
|
||||||
|
const childPath = path.child(index.toString());
|
||||||
|
const childInfo = inferType(value);
|
||||||
|
|
||||||
|
const children = createSearchEntryChildren(childInfo, childPath);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path: childPath.toString(),
|
||||||
|
rawValue: getRawValue(childInfo),
|
||||||
|
formattedValue: formatValue(childInfo, { leafNodesOnly: true }),
|
||||||
|
},
|
||||||
|
...children,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.name === "object" && info.value) {
|
||||||
|
return Object.entries(info.value).flatMap(([key, value]) => {
|
||||||
|
const childPath = path.child(key);
|
||||||
|
const childInfo = inferType(value);
|
||||||
|
|
||||||
|
const children = createSearchEntryChildren(childInfo, childPath);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path: childPath.toString(),
|
||||||
|
rawValue: getRawValue(childInfo),
|
||||||
|
formattedValue: formatValue(childInfo, { leafNodesOnly: true }),
|
||||||
|
},
|
||||||
|
...children,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRawValue(type: JSONValueType): string | undefined {
|
||||||
|
switch (type.name) {
|
||||||
|
case "string":
|
||||||
|
return type.value;
|
||||||
|
case "int":
|
||||||
|
return type.value.toString();
|
||||||
|
case "float":
|
||||||
|
return type.value.toString();
|
||||||
|
case "bool":
|
||||||
|
return type.value ? "true" : "false";
|
||||||
|
case "null":
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+21
-7
@@ -27,6 +27,7 @@
|
|||||||
"color": "^4.2.1",
|
"color": "^4.2.1",
|
||||||
"fathom-client": "^3.4.1",
|
"fathom-client": "^3.4.1",
|
||||||
"framer-motion": "^6.2.4",
|
"framer-motion": "^6.2.4",
|
||||||
|
"fuse.js": "^6.5.3",
|
||||||
"json-source-map": "^0.6.1",
|
"json-source-map": "^0.6.1",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
@@ -58,7 +59,7 @@
|
|||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"tailwindcss": "^3.0.22",
|
"tailwindcss": "^3.0.22",
|
||||||
"ts-jest": "^27.1.3",
|
"ts-jest": "^27.1.3",
|
||||||
"typescript": "^4.1.2"
|
"typescript": "^4.6.x"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@@ -5925,6 +5926,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/fuse.js": {
|
||||||
|
"version": "6.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.5.3.tgz",
|
||||||
|
"integrity": "sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -12791,9 +12800,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "4.5.5",
|
"version": "4.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
|
||||||
"integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==",
|
"integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -18048,6 +18057,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||||
},
|
},
|
||||||
|
"fuse.js": {
|
||||||
|
"version": "6.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.5.3.tgz",
|
||||||
|
"integrity": "sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg=="
|
||||||
|
},
|
||||||
"gensync": {
|
"gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -23005,9 +23019,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"version": "4.5.5",
|
"version": "4.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
|
||||||
"integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==",
|
"integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"unbox-primitive": {
|
"unbox-primitive": {
|
||||||
|
|||||||
+7
-4
@@ -5,14 +5,16 @@
|
|||||||
"license": "apache-2.0",
|
"license": "apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"build": "npm run build:css && remix build",
|
"build": "npm run build:css && npm run build:search && remix build",
|
||||||
"build:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --minify",
|
"build:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --minify",
|
||||||
"build:worker": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --outdir=dist ./worker",
|
"build:worker": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --outdir=dist ./worker",
|
||||||
"build:worker:analyze": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --metafile=meta.json --outdir=dist ./worker",
|
"build:worker:analyze": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --metafile=meta.json --outdir=dist ./worker",
|
||||||
"build:visualize": "npm run clean && npm run build && npm run build:worker:analyze && esbuild-visualizer --metadata ./meta.json --exclude *.png",
|
"build:visualize": "npm run clean && npm run build && npm run build:worker:analyze && esbuild-visualizer --metadata ./meta.json --exclude *.png",
|
||||||
|
"build:search": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
|
||||||
|
"dev:search": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
|
||||||
"dev:worker": "esbuild --define:process.env.NODE_ENV='\"development\"' --bundle --sourcemap --outdir=dist ./worker",
|
"dev:worker": "esbuild --define:process.env.NODE_ENV='\"development\"' --bundle --sourcemap --outdir=dist ./worker",
|
||||||
"start:worker": "miniflare --env .env --build-command \"npm run dev:worker\" --watch",
|
"start:worker": "miniflare --env .env --build-command \"npm run dev:worker\" --watch",
|
||||||
"dev": "concurrently \"npm run dev:css\" \"remix watch\"",
|
"dev": "concurrently \"npm run dev:css\" \"npm run dev:search\" \"remix watch\"",
|
||||||
"dev:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --watch",
|
"dev:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --watch",
|
||||||
"postinstall": "remix setup cloudflare-workers",
|
"postinstall": "remix setup cloudflare-workers",
|
||||||
"start": "concurrently \"npm run dev\" \"npm run start:worker\"",
|
"start": "concurrently \"npm run dev\" \"npm run start:worker\"",
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"color": "^4.2.1",
|
"color": "^4.2.1",
|
||||||
"fathom-client": "^3.4.1",
|
"fathom-client": "^3.4.1",
|
||||||
"framer-motion": "^6.2.4",
|
"framer-motion": "^6.2.4",
|
||||||
|
"fuse.js": "^6.5.3",
|
||||||
"json-source-map": "^0.6.1",
|
"json-source-map": "^0.6.1",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
@@ -75,7 +78,7 @@
|
|||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"tailwindcss": "^3.0.22",
|
"tailwindcss": "^3.0.22",
|
||||||
"ts-jest": "^27.1.3",
|
"ts-jest": "^27.1.3",
|
||||||
"typescript": "^4.1.2"
|
"typescript": "^4.6.x"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@@ -100,4 +103,4 @@
|
|||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"main": "dist/worker.js"
|
"main": "dist/worker.js"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import Fuse from "fuse.js";
|
||||||
|
import {
|
||||||
|
createSearchEntries,
|
||||||
|
createSearchIndex,
|
||||||
|
} from "../app/utilities/search";
|
||||||
|
|
||||||
|
const json = {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
createdAt: "2020-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2020-12-02T11:34:00.000Z",
|
||||||
|
name: "John Doe",
|
||||||
|
email: "john@doe.com",
|
||||||
|
website: "https://john.doe.com",
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
productName: "Product 1",
|
||||||
|
quantity: 1,
|
||||||
|
price: 10.0,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
productName: "Product 2",
|
||||||
|
quantity: 100,
|
||||||
|
price: 999.0,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
createdAt: "2020-01-02T00:00:00.000Z",
|
||||||
|
updatedAt: "2020-12-03T07:55:32.000Z",
|
||||||
|
name: "Jane Doe",
|
||||||
|
email: "jane@icloud.com",
|
||||||
|
website: "https://jane.doe.co.uk",
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
productName: "Product 3",
|
||||||
|
quantity: 1,
|
||||||
|
price: 10.0,
|
||||||
|
currency: "GBP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
productName: "Product 1",
|
||||||
|
quantity: 2,
|
||||||
|
price: 76.45,
|
||||||
|
currency: "GBP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createSearchIndex", () => {
|
||||||
|
it("creates a search index that can search keys, raw values, and formatted values", () => {
|
||||||
|
const [index, entries] = createSearchIndex(json);
|
||||||
|
|
||||||
|
const fuse = new Fuse(
|
||||||
|
entries,
|
||||||
|
{
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
minMatchCharLength: 1,
|
||||||
|
isCaseSensitive: false,
|
||||||
|
threshold: 0.6,
|
||||||
|
distance: 200,
|
||||||
|
},
|
||||||
|
index
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchResults = fuse.search("john doe");
|
||||||
|
|
||||||
|
expect(searchResults[0].item.path).toBe("$.records.0.name");
|
||||||
|
expect(searchResults[1].item.path).toBe("$.records.0.email");
|
||||||
|
expect(searchResults[2].item.path).toBe("$.records.0.website");
|
||||||
|
expect(searchResults[3].item.path).toBe("$.records.1.name");
|
||||||
|
|
||||||
|
const searchResultsPaths = fuse.search("currency");
|
||||||
|
|
||||||
|
expect(searchResultsPaths[0].item.path).toBe(
|
||||||
|
"$.records.0.orders.0.currency"
|
||||||
|
);
|
||||||
|
expect(searchResultsPaths[1].item.path).toBe(
|
||||||
|
"$.records.0.orders.1.currency"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(searchResultsPaths[2].item.path).toBe(
|
||||||
|
"$.records.1.orders.0.currency"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(searchResultsPaths[3].item.path).toBe(
|
||||||
|
"$.records.1.orders.1.currency"
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchResultsFormattedValues = fuse.search("dec 2");
|
||||||
|
|
||||||
|
expect(searchResultsFormattedValues[0].item.path).toBe(
|
||||||
|
"$.records.0.updatedAt"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createSearchEntries", () => {
|
||||||
|
it("creates searchable entries for the passed in json", () => {
|
||||||
|
expect(createSearchEntries(json)).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"formattedValue": undefined,
|
||||||
|
"path": "$.records",
|
||||||
|
"rawValue": undefined,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": undefined,
|
||||||
|
"path": "$.records.0",
|
||||||
|
"rawValue": undefined,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "1",
|
||||||
|
"path": "$.records.0.id",
|
||||||
|
"rawValue": "1",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "Jan 1, 2020, 12:00:00 AM GMT",
|
||||||
|
"path": "$.records.0.createdAt",
|
||||||
|
"rawValue": "2020-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "Dec 2, 2020, 11:34:00 AM GMT",
|
||||||
|
"path": "$.records.0.updatedAt",
|
||||||
|
"rawValue": "2020-12-02T11:34:00.000Z",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "John Doe",
|
||||||
|
"path": "$.records.0.name",
|
||||||
|
"rawValue": "John Doe",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "john@doe.com",
|
||||||
|
"path": "$.records.0.email",
|
||||||
|
"rawValue": "john@doe.com",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "https://john.doe.com",
|
||||||
|
"path": "$.records.0.website",
|
||||||
|
"rawValue": "https://john.doe.com",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": undefined,
|
||||||
|
"path": "$.records.0.orders",
|
||||||
|
"rawValue": undefined,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": undefined,
|
||||||
|
"path": "$.records.0.orders.0",
|
||||||
|
"rawValue": undefined,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "1",
|
||||||
|
"path": "$.records.0.orders.0.id",
|
||||||
|
"rawValue": "1",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "Product 1",
|
||||||
|
"path": "$.records.0.orders.0.productName",
|
||||||
|
"rawValue": "Product 1",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "1",
|
||||||
|
"path": "$.records.0.orders.0.quantity",
|
||||||
|
"rawValue": "1",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "10",
|
||||||
|
"path": "$.records.0.orders.0.price",
|
||||||
|
"rawValue": "10",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "USD",
|
||||||
|
"path": "$.records.0.orders.0.currency",
|
||||||
|
"rawValue": "USD",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": undefined,
|
||||||
|
"path": "$.records.0.orders.1",
|
||||||
|
"rawValue": undefined,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "2",
|
||||||
|
"path": "$.records.0.orders.1.id",
|
||||||
|
"rawValue": "2",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "Product 2",
|
||||||
|
"path": "$.records.0.orders.1.productName",
|
||||||
|
"rawValue": "Product 2",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "100",
|
||||||
|
"path": "$.records.0.orders.1.quantity",
|
||||||
|
"rawValue": "100",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "999",
|
||||||
|
"path": "$.records.0.orders.1.price",
|
||||||
|
"rawValue": "999",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "USD",
|
||||||
|
"path": "$.records.0.orders.1.currency",
|
||||||
|
"rawValue": "USD",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": undefined,
|
||||||
|
"path": "$.records.1",
|
||||||
|
"rawValue": undefined,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "2",
|
||||||
|
"path": "$.records.1.id",
|
||||||
|
"rawValue": "2",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "Jan 2, 2020, 12:00:00 AM GMT",
|
||||||
|
"path": "$.records.1.createdAt",
|
||||||
|
"rawValue": "2020-01-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "Dec 3, 2020, 7:55:32 AM GMT",
|
||||||
|
"path": "$.records.1.updatedAt",
|
||||||
|
"rawValue": "2020-12-03T07:55:32.000Z",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "Jane Doe",
|
||||||
|
"path": "$.records.1.name",
|
||||||
|
"rawValue": "Jane Doe",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "jane@icloud.com",
|
||||||
|
"path": "$.records.1.email",
|
||||||
|
"rawValue": "jane@icloud.com",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "https://jane.doe.co.uk",
|
||||||
|
"path": "$.records.1.website",
|
||||||
|
"rawValue": "https://jane.doe.co.uk",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": undefined,
|
||||||
|
"path": "$.records.1.orders",
|
||||||
|
"rawValue": undefined,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": undefined,
|
||||||
|
"path": "$.records.1.orders.0",
|
||||||
|
"rawValue": undefined,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "3",
|
||||||
|
"path": "$.records.1.orders.0.id",
|
||||||
|
"rawValue": "3",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "Product 3",
|
||||||
|
"path": "$.records.1.orders.0.productName",
|
||||||
|
"rawValue": "Product 3",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "1",
|
||||||
|
"path": "$.records.1.orders.0.quantity",
|
||||||
|
"rawValue": "1",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "10",
|
||||||
|
"path": "$.records.1.orders.0.price",
|
||||||
|
"rawValue": "10",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "GBP",
|
||||||
|
"path": "$.records.1.orders.0.currency",
|
||||||
|
"rawValue": "GBP",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": undefined,
|
||||||
|
"path": "$.records.1.orders.1",
|
||||||
|
"rawValue": undefined,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "4",
|
||||||
|
"path": "$.records.1.orders.1.id",
|
||||||
|
"rawValue": "4",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "Product 1",
|
||||||
|
"path": "$.records.1.orders.1.productName",
|
||||||
|
"rawValue": "Product 1",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "2",
|
||||||
|
"path": "$.records.1.orders.1.quantity",
|
||||||
|
"rawValue": "2",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "76.45",
|
||||||
|
"path": "$.records.1.orders.1.price",
|
||||||
|
"rawValue": "76.45",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"formattedValue": "GBP",
|
||||||
|
"path": "$.records.1.orders.1.currency",
|
||||||
|
"rawValue": "GBP",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user