@findlane/browser 0.16.0 → 0.17.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.
package/dist/index.d.ts CHANGED
@@ -46,6 +46,8 @@ export type SearchRequest = {
46
46
  /** Only records inside a box, in degrees, such as a map's visible area. West greater than east crosses the
47
47
  * antimeridian. */
48
48
  within?: GeoBox;
49
+ /** With the index's related results on: false asks for none (0.17.0). */
50
+ related?: boolean;
49
51
  };
50
52
  /** A point in degrees: lat from -90 to 90, lng from -180 to 180. */
51
53
  export type GeoPoint = {
@@ -59,16 +61,18 @@ export type GeoBox = {
59
61
  east: number;
60
62
  west: number;
61
63
  };
64
+ /** A result. */
65
+ export type SearchHit<T> = {
66
+ id: string;
67
+ score: number | null;
68
+ record: T;
69
+ /** With `near`: meters from the point. */
70
+ distance?: number;
71
+ /** With `highlight`: matched searchable fields by path; list fields give one string per value. */
72
+ highlights?: Record<string, string | string[]>;
73
+ };
62
74
  export type SearchResponse<T> = {
63
- hits: {
64
- id: string;
65
- score: number | null;
66
- record: T;
67
- /** With `near`: meters from the point. */
68
- distance?: number;
69
- /** With `highlight`: matched searchable fields by path; list fields give one string per value. */
70
- highlights?: Record<string, string | string[]>;
71
- }[];
75
+ hits: SearchHit<T>[];
72
76
  total: number;
73
77
  /** "gte" when `total` is a lower bound (page count mode). */
74
78
  totalRelation?: "eq" | "gte";
@@ -95,6 +99,13 @@ export type SearchResponse<T> = {
95
99
  appliedRules?: AppliedRule[];
96
100
  /** When a rule replaced words: the query that was searched. */
97
101
  rewrittenQuery?: string;
102
+ /** With the index's related results on (0.17.0): when the query's words found nothing (`hits` empty, `total`
103
+ * 0), the products closest to it in meaning, best first, with their similarity as `score`. Show them under
104
+ * a heading of their own, such as "No exact matches. Related products". */
105
+ related?: {
106
+ hits: SearchHit<T>[];
107
+ total: number;
108
+ };
98
109
  };
99
110
  /** A query rule a search fired, with its `userData`, if it has any. */
100
111
  export type AppliedRule = {
@@ -227,7 +238,7 @@ export type EventItem = {
227
238
  export type ClickDetails = {
228
239
  position?: number;
229
240
  query?: string;
230
- list?: "goesWith" | "similar" | "together";
241
+ list?: "goesWith" | "similar" | "together" | "related";
231
242
  from?: string;
232
243
  };
233
244
  /** Events from the page, sent in the background: a batch a second, and what's left when the page is
@@ -1,6 +1,8 @@
1
1
  import type { QuerySuggestion, RecommendationsRequest, RecommendationsResponse, SearchBrowserClient, SearchRequest, SearchResponse } from "../index.js";
2
2
  /** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
3
- * the query's first eight lowercase words, defaults left out, and list order ignored. */
3
+ * the query's first eight lowercase words, defaults left out, and list order ignored. Words keep an
4
+ * apostrophe or period inside them (don't, 3.5), which the API matches as one word; the edge's exact
5
+ * rule needs lookbehind, which older Safari can't parse, so this keeps a little more apart than it. */
4
6
  export declare function searchKey(request: SearchRequest): string;
5
7
  /** A product's recommendations share a key when only defaults differ: the limit of 8 and empty filters. */
6
8
  export declare function recommendationsKey(objectID: string, request?: RecommendationsRequest): string;
@@ -6,11 +6,14 @@ const searches = store();
6
6
  const recommendations = store();
7
7
  const suggestions = store();
8
8
  /** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
9
- * the query's first eight lowercase words, defaults left out, and list order ignored. */
9
+ * the query's first eight lowercase words, defaults left out, and list order ignored. Words keep an
10
+ * apostrophe or period inside them (don't, 3.5), which the API matches as one word; the edge's exact
11
+ * rule needs lookbehind, which older Safari can't parse, so this keeps a little more apart than it. */
10
12
  export function searchKey(request) {
11
13
  const normal = { ...request };
12
14
  if (typeof normal.q === "string" && normal.q.length <= 200)
13
- normal.q = normal.q.toLowerCase().match(/[\p{L}\p{N}]+/gu)?.slice(0, 8).join(" ") ?? "";
15
+ normal.q = (normal.q.toLowerCase().replace(/[’‘]/gu, "'").match(/[\p{L}\p{M}\p{N}_'.:·,;]+/gu) ?? [])
16
+ .map((word) => word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "")).filter(Boolean).slice(0, 8).join(" ");
14
17
  const defaults = { q: "", limit: 20, offset: 0, countMode: "page", facetMode: "page" };
15
18
  for (const [key, value] of Object.entries(defaults))
16
19
  if (normal[key] === value)
@@ -56,6 +56,9 @@ export type HitsProps<T> = {
56
56
  render?: (hit: Hit<T>, index: number) => ReactNode;
57
57
  /** Shown when nothing matches (default "No results for “…”"). */
58
58
  empty?: (query: string) => ReactNode;
59
+ /** The heading over related results, shown when nothing matched but the index's related results found
60
+ * products close in meaning (default "No exact matches. Related products"). */
61
+ relatedTitle?: (query: string) => ReactNode;
59
62
  /** Shown before the first results arrive. */
60
63
  loading?: ReactNode;
61
64
  className?: string;
@@ -63,7 +66,7 @@ export type HitsProps<T> = {
63
66
  /** Whether a click was on a link inside the element that handled it, such as a hit's link. */
64
67
  export declare function onLink(event: MouseEvent<HTMLElement>): boolean;
65
68
  /** The results. Following a link in a hit sends a click event, with its place and the query. */
66
- export declare function Hits<T = Record<string, unknown>>({ render, empty, loading, className }: HitsProps<T>): import("react").JSX.Element;
69
+ export declare function Hits<T = Record<string, unknown>>({ render, empty, relatedTitle, loading, className }: HitsProps<T>): import("react").JSX.Element;
67
70
  export type RefinementListProps = {
68
71
  field: string;
69
72
  /** The heading (default the field). */
@@ -74,22 +74,27 @@ export function onLink(event) {
74
74
  return Boolean(link && event.currentTarget.contains(link));
75
75
  }
76
76
  /** The results. Following a link in a hit sends a click event, with its place and the query. */
77
- export function Hits({ render, empty, loading = null, className }) {
78
- const { hits, isLoaded, isPending, error, click } = useHits();
77
+ export function Hits({ render, empty, relatedTitle, loading = null, className }) {
78
+ const { hits, related, isLoaded, isPending, error, click } = useHits();
79
79
  const { query } = useSearchBox();
80
80
  if (error && !isLoaded)
81
81
  return _jsx("p", { "data-findlane-note": "", children: messageFor(error) });
82
82
  if (!isLoaded)
83
83
  return _jsx(_Fragment, { children: loading });
84
- if (!hits.length)
84
+ if (!hits.length && !related.length)
85
85
  return _jsx("p", { "data-findlane-empty": "", children: empty ? empty(query) : query ? _jsxs(_Fragment, { children: ["No results for \u201C", query, "\u201D"] }) : "No results" });
86
- return (_jsx("ol", { className: className, "data-findlane-hits": "", "data-pending": isPending || undefined, children: hits.map((hit, index) => {
86
+ // Nothing matched, but related results found products close in meaning: those, under their own heading.
87
+ const shown = hits.length ? hits : related;
88
+ const list = (_jsx("ol", { className: className, "data-findlane-hits": "", "data-findlane-related": !hits.length || undefined, "data-pending": isPending || undefined, children: shown.map((hit, index) => {
87
89
  // A click, and a middle click that opens the link in a new tab.
88
90
  const clicked = (event) => { if (!event.defaultPrevented && onLink(event))
89
91
  click(hit); };
90
92
  return (_jsx("li", { onClick: clicked, onAuxClick: (event) => { if (event.button === 1)
91
93
  clicked(event); }, children: render ? render(hit, index) : _jsx(DefaultHit, { hit: hit }) }, hit.id));
92
94
  }) }));
95
+ if (hits.length)
96
+ return list;
97
+ return (_jsxs(_Fragment, { children: [_jsx("p", { "data-findlane-related-title": "", children: relatedTitle ? relatedTitle(query) : "No exact matches. Related products" }), list] }));
93
98
  }
94
99
  /** Checkboxes for a facet's values, with counts. */
95
100
  export function RefinementList({ field, label, limit = 8, format, className }) {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Fragment } from "react";
3
- // The API escapes highlighted values (dashboard/search-terms.ts) and wraps matched words in
3
+ // The API escapes highlighted values (the engine's, engine/src/present.rs) and wraps matched words in
4
4
  // <mark>. Splitting on the tags and decoding the five escapes gives text React can render, so
5
5
  // nothing is inserted as HTML.
6
6
  const escapes = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'" };
@@ -35,6 +35,8 @@ export declare function useSearchBox(): {
35
35
  export declare function useHits<T = Record<string, unknown>>(): {
36
36
  hits: import("./state.js").Hit<T>[];
37
37
  total: number;
38
+ /** With the index's related results on, when nothing matched: the products closest in meaning. */
39
+ related: import("../index.js").SearchHit<T>[];
38
40
  /** "gte" when `total` is a lower bound (with `countMode: "page"`). */
39
41
  totalRelation: "eq" | "gte";
40
42
  response: import("../index.js").SearchResponse<T> | null;
@@ -82,6 +82,8 @@ export function useHits() {
82
82
  return {
83
83
  hits: results?.hits ?? [],
84
84
  total: results?.response.total ?? 0,
85
+ /** With the index's related results on, when nothing matched: the products closest in meaning. */
86
+ related: results?.response.related?.hits ?? [],
85
87
  /** "gte" when `total` is a lower bound (with `countMode: "page"`). */
86
88
  totalRelation: results?.response.totalRelation ?? "eq",
87
89
  response: results?.response ?? null,
@@ -62,7 +62,8 @@ export declare class SearchStore<T = unknown> {
62
62
  get settings(): SearchConfig;
63
63
  /** Opens the connection to the API before the first search needs it (the client's warm). */
64
64
  warm(): void;
65
- /** Sends a click on a result: its place in the results, from 1, and the query they're for. */
65
+ /** Sends a click on a result: its place in the results, from 1, and the query they're for. A related
66
+ * result's click says so (list "related"), with its place among them. */
66
67
  click(objectID: string): void;
67
68
  /** Searches unless the results already answer the current state; with a state, such as the
68
69
  * URL's, adopts it first. */
@@ -67,12 +67,15 @@ export class SearchStore {
67
67
  get settings() { return this.config; }
68
68
  /** Opens the connection to the API before the first search needs it (the client's warm). */
69
69
  warm() { this.client.warm(); }
70
- /** Sends a click on a result: its place in the results, from 1, and the query they're for. */
70
+ /** Sends a click on a result: its place in the results, from 1, and the query they're for. A related
71
+ * result's click says so (list "related"), with its place among them. */
71
72
  click(objectID) {
72
73
  const results = this.snapshot.results;
73
74
  const index = results ? results.hits.findIndex((hit) => hit.id === objectID) : -1;
75
+ const related = index < 0 && results ? (results.response.related?.hits ?? []).findIndex((hit) => hit.id === objectID) : -1;
74
76
  this.client.events.click(objectID, { query: results?.request.q ?? "",
75
- ...(index >= 0 ? { position: (results.request.offset ?? 0) + index + 1 } : {}) });
77
+ ...(index >= 0 ? { position: (results.request.offset ?? 0) + index + 1 } : {}),
78
+ ...(related >= 0 ? { position: related + 1, list: "related" } : {}) });
76
79
  }
77
80
  /** Searches unless the results already answer the current state; with a state, such as the
78
81
  * URL's, adopts it first. */
@@ -63,13 +63,7 @@ export declare function fillTemplate<T>(template: string, hit: Hit<T>): string |
63
63
  export declare function useAutocomplete<T = Record<string, unknown>>(options?: AutocompleteOptions<T>): {
64
64
  query: string;
65
65
  setQuery: (value: string) => void;
66
- hits: {
67
- id: string;
68
- score: number | null;
69
- record: T;
70
- distance?: number;
71
- highlights?: Record<string, string | string[]>;
72
- }[];
66
+ hits: import("../index.js").SearchHit<T>[];
73
67
  total: number;
74
68
  response: import("../index.js").SearchResponse<T> | null;
75
69
  error: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@findlane/browser",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Findlane browser SDK: fast, typo-tolerant public search from the browser with an index's public ID, plus React hooks and components.",
5
5
  "keywords": ["findlane", "search", "full-text search", "typo tolerance", "instant search", "ecommerce search", "browser", "react", "autocomplete"],
6
6
  "license": "MIT",