@findlane/browser 0.8.0 → 0.9.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 +10 -1
- package/dist/react/components.d.ts +4 -1
- package/dist/react/components.js +14 -2
- package/dist/react/recommendations.d.ts +22 -7
- package/dist/react/recommendations.js +30 -8
- package/dist/react/search-provider.d.ts +5 -0
- package/dist/react/search-provider.js +4 -1
- package/dist/react/store.d.ts +2 -0
- package/dist/react/store.js +7 -0
- package/dist/react/styles.css +9 -1
- package/dist/react/use-autocomplete.d.ts +4 -2
- package/dist/react/use-autocomplete.js +17 -4
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -110,14 +110,23 @@ export type RecommendationHit<T> = {
|
|
|
110
110
|
score: number | null;
|
|
111
111
|
record: T;
|
|
112
112
|
};
|
|
113
|
+
/** A Bought together pick: `count` is the orders in the last year that had both products. */
|
|
114
|
+
export type TogetherHit<T> = RecommendationHit<T> & {
|
|
115
|
+
count: number;
|
|
116
|
+
};
|
|
113
117
|
export type RecommendationsResponse<T> = {
|
|
114
118
|
objectID: string;
|
|
115
|
-
/** false until the product's lists are built;
|
|
119
|
+
/** false until the product's lists are built; the lists are empty meanwhile. */
|
|
116
120
|
ready: boolean;
|
|
117
121
|
/** Products a shopper would want with this one, the likeliest first; `score` is the decision model's probability. */
|
|
118
122
|
goesWith: RecommendationHit<T>[];
|
|
119
123
|
/** Products a shopper could choose instead, then (unless the index hides them) variants of this one. */
|
|
120
124
|
similar: RecommendationHit<T>[];
|
|
125
|
+
/** Products bought in the same orders as this one, the most orders first, from the orders the store
|
|
126
|
+
* sends as events. Empty without orders. */
|
|
127
|
+
together?: TogetherHit<T>[];
|
|
128
|
+
/** What `together`'s counts are of. */
|
|
129
|
+
togetherBasis?: "orders";
|
|
121
130
|
};
|
|
122
131
|
export declare class FindlaneError extends Error {
|
|
123
132
|
status: number;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { type MouseEvent, type ReactNode } from "react";
|
|
2
2
|
import type { DidYouMean as Correction } from "../index.js";
|
|
3
3
|
import { type Refinement } from "./search-provider.js";
|
|
4
4
|
import type { Hit } from "./state.js";
|
|
@@ -48,6 +48,9 @@ export type HitsProps<T> = {
|
|
|
48
48
|
loading?: ReactNode;
|
|
49
49
|
className?: string;
|
|
50
50
|
};
|
|
51
|
+
/** Whether a click was on a link inside the element that handled it, such as a hit's link. */
|
|
52
|
+
export declare function onLink(event: MouseEvent<HTMLElement>): boolean;
|
|
53
|
+
/** The results. Following a link in a hit sends a click event, with its place and the query. */
|
|
51
54
|
export declare function Hits<T = Record<string, unknown>>({ render, empty, loading, className }: HitsProps<T>): import("react").JSX.Element;
|
|
52
55
|
export type RefinementListProps = {
|
|
53
56
|
field: string;
|
package/dist/react/components.js
CHANGED
|
@@ -38,8 +38,14 @@ export function SearchInput({ placeholder, label = "Search", className, autoFocu
|
|
|
38
38
|
const { query, setQuery } = useSearchBox();
|
|
39
39
|
return (_jsx("form", { role: "search", className: className, "data-findlane-search-input": "", onSubmit: (event) => event.preventDefault(), children: _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: placeholder, "aria-label": label, autoFocus: autoFocus, autoComplete: "off", spellCheck: false, maxLength: 200 }) }));
|
|
40
40
|
}
|
|
41
|
+
/** Whether a click was on a link inside the element that handled it, such as a hit's link. */
|
|
42
|
+
export function onLink(event) {
|
|
43
|
+
const link = event.target.closest?.("a[href]");
|
|
44
|
+
return Boolean(link && event.currentTarget.contains(link));
|
|
45
|
+
}
|
|
46
|
+
/** The results. Following a link in a hit sends a click event, with its place and the query. */
|
|
41
47
|
export function Hits({ render, empty, loading = null, className }) {
|
|
42
|
-
const { hits, isLoaded, isPending, error } = useHits();
|
|
48
|
+
const { hits, isLoaded, isPending, error, click } = useHits();
|
|
43
49
|
const { query } = useSearchBox();
|
|
44
50
|
if (error && !isLoaded)
|
|
45
51
|
return _jsx("p", { "data-findlane-note": "", children: messageFor(error) });
|
|
@@ -47,7 +53,13 @@ export function Hits({ render, empty, loading = null, className }) {
|
|
|
47
53
|
return _jsx(_Fragment, { children: loading });
|
|
48
54
|
if (!hits.length)
|
|
49
55
|
return _jsx("p", { "data-findlane-empty": "", children: empty ? empty(query) : query ? _jsxs(_Fragment, { children: ["No results for \u201C", query, "\u201D"] }) : "No results" });
|
|
50
|
-
return (_jsx("ol", { className: className, "data-findlane-hits": "", "data-pending": isPending || undefined, children: hits.map((hit, index) =>
|
|
56
|
+
return (_jsx("ol", { className: className, "data-findlane-hits": "", "data-pending": isPending || undefined, children: hits.map((hit, index) => {
|
|
57
|
+
// A click, and a middle click that opens the link in a new tab.
|
|
58
|
+
const clicked = (event) => { if (!event.defaultPrevented && onLink(event))
|
|
59
|
+
click(hit); };
|
|
60
|
+
return (_jsx("li", { onClick: clicked, onAuxClick: (event) => { if (event.button === 1)
|
|
61
|
+
clicked(event); }, children: render ? render(hit, index) : _jsx(DefaultHit, { hit: hit }) }, hit.id));
|
|
62
|
+
}) }));
|
|
51
63
|
}
|
|
52
64
|
/** Checkboxes for a facet's values, with counts. */
|
|
53
65
|
export function RefinementList({ field, label, limit = 8, format, className }) {
|
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { RecommendationsRequest } from "../index.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type ClientOptions } from "./client.js";
|
|
4
4
|
import type { Hit } from "./state.js";
|
|
5
|
+
type List = "goesWith" | "similar" | "together";
|
|
5
6
|
export type RecommendationsProps<T> = ClientOptions & RecommendationsRequest & {
|
|
6
7
|
/** The product whose recommendations to show. */
|
|
7
8
|
objectID: string;
|
|
8
|
-
/** Products that go with it (the default),
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/** Products that go with it (the default), similar products, or products bought in the same orders
|
|
10
|
+
* ("together", from the orders the store sends as events). */
|
|
11
|
+
list?: List;
|
|
12
|
+
/** Another list that fills a short one, from the same response: `list="together" fill="goesWith"`
|
|
13
|
+
* gives a product with few orders a full list too. */
|
|
14
|
+
fill?: "goesWith" | "similar";
|
|
15
|
+
/** The heading; null for none. By default the list's: "Goes well with", "Similar products", or
|
|
16
|
+
* "Frequently bought together" when most products shown come from orders (otherwise the fill list's). */
|
|
11
17
|
title?: ReactNode;
|
|
12
18
|
/** A product's content; DefaultHit shows an image and a title. */
|
|
13
19
|
render?: (hit: Hit<T>, index: number) => ReactNode;
|
|
20
|
+
/** A product's page: each product (DefaultHit, or what `render` gives) becomes a link to it. Leave it
|
|
21
|
+
* out when `render` has its own link. */
|
|
22
|
+
getHref?: (hit: Hit<T>) => string | undefined;
|
|
23
|
+
/** A product's page as a template, for server components, which can't pass `getHref`:
|
|
24
|
+
* "/products/{handle}" puts in the record's `handle`, URL-encoded, and `{objectID}` the product's ID. */
|
|
25
|
+
hrefTemplate?: string;
|
|
14
26
|
/** Shown while the lists load. */
|
|
15
27
|
loading?: ReactNode;
|
|
16
28
|
className?: string;
|
|
17
29
|
};
|
|
18
30
|
/** One of a product's recommendation lists (the recommendations add-on). It renders nothing while the
|
|
19
|
-
* product has none, so a product page can always include it.
|
|
20
|
-
*
|
|
21
|
-
|
|
31
|
+
* product has none, so a product page can always include it. Lists for the same product share one
|
|
32
|
+
* request. Each product says which list it came from, in data-findlane-source. Following a link in a
|
|
33
|
+
* product sends a click event with its list, place, and the product the list is for (the client's
|
|
34
|
+
* `events` option turns them off, or holds them until consent). */
|
|
35
|
+
export declare function Recommendations<T = Record<string, unknown>>({ objectID, list, fill, title, render, getHref, hrefTemplate, loading, className, ...options }: RecommendationsProps<T>): import("react").JSX.Element | null;
|
|
36
|
+
export {};
|
|
@@ -1,17 +1,39 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { useClient } from "./client.js";
|
|
4
|
+
import { DefaultHit, onLink } from "./components.js";
|
|
5
|
+
import { fillTemplate } from "./use-autocomplete.js";
|
|
4
6
|
import { useRecommendations } from "./use-recommendations.js";
|
|
7
|
+
const headings = { goesWith: "Goes well with", similar: "Similar products", together: "Frequently bought together" };
|
|
5
8
|
/** One of a product's recommendation lists (the recommendations add-on). It renders nothing while the
|
|
6
|
-
* product has none, so a product page can always include it.
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
+
* product has none, so a product page can always include it. Lists for the same product share one
|
|
10
|
+
* request. Each product says which list it came from, in data-findlane-source. Following a link in a
|
|
11
|
+
* product sends a click event with its list, place, and the product the list is for (the client's
|
|
12
|
+
* `events` option turns them off, or holds them until consent). */
|
|
13
|
+
export function Recommendations({ objectID, list = "goesWith", fill, title, render, getHref, hrefTemplate, loading = null, className, ...options }) {
|
|
9
14
|
const { data, isPending } = useRecommendations(objectID, options);
|
|
15
|
+
const client = useClient({ client: options.client, publicId: options.publicId, baseUrl: options.baseUrl });
|
|
10
16
|
if (!data)
|
|
11
17
|
return isPending ? _jsx(_Fragment, { children: loading }) : null;
|
|
12
|
-
const
|
|
13
|
-
|
|
18
|
+
const limit = options.limit ?? 8;
|
|
19
|
+
const own = (data[list] ?? []).slice(0, limit).map((hit) => ({ hit, source: list }));
|
|
20
|
+
const taken = new Set([objectID, ...own.map((item) => item.hit.id)]);
|
|
21
|
+
const extra = fill && fill !== list ? (data[fill] ?? []).filter((hit) => !taken.has(hit.id))
|
|
22
|
+
.slice(0, limit - own.length).map((hit) => ({ hit, source: fill })) : [];
|
|
23
|
+
const items = [...own, ...extra];
|
|
24
|
+
if (!items.length)
|
|
14
25
|
return null;
|
|
15
|
-
|
|
16
|
-
|
|
26
|
+
// A heading that says what most of the list is.
|
|
27
|
+
const heading = title === undefined ? headings[fill && own.length * 2 < items.length ? fill : list] : title;
|
|
28
|
+
return (_jsxs("section", { className: className, "data-findlane-recommendations": list, children: [heading !== null && _jsx("h2", { "data-findlane-recommendations-title": "", children: heading }), _jsx("ol", { "data-findlane-hits": "", children: items.map(({ hit, source }, index) => {
|
|
29
|
+
const href = getHref ? getHref(hit) : hrefTemplate ? fillTemplate(hrefTemplate, hit) : undefined;
|
|
30
|
+
const content = render ? render(hit, index) : _jsx(DefaultHit, { hit: hit });
|
|
31
|
+
// A click, and a middle click that opens the link in a new tab.
|
|
32
|
+
const clicked = (event) => {
|
|
33
|
+
if (!event.defaultPrevented && onLink(event))
|
|
34
|
+
client.events.click(hit.id, { list: source, from: objectID, position: index + 1 });
|
|
35
|
+
};
|
|
36
|
+
return (_jsx("li", { "data-findlane-source": source, onClick: clicked, onAuxClick: (event) => { if (event.button === 1)
|
|
37
|
+
clicked(event); }, children: href ? _jsx("a", { href: href, "data-findlane-link": "", children: content }) : content }, hit.id));
|
|
38
|
+
}) })] }));
|
|
17
39
|
}
|
|
@@ -45,6 +45,11 @@ export declare function useHits<T = Record<string, unknown>>(): {
|
|
|
45
45
|
isLoaded: boolean;
|
|
46
46
|
isPending: boolean;
|
|
47
47
|
error: unknown;
|
|
48
|
+
/** Sends a click event for a hit, with its place and the query, for click-through in Analytics.
|
|
49
|
+
* Hits does it for links in a hit; call it from a hit rendered without a link. */
|
|
50
|
+
click: (hit: {
|
|
51
|
+
id: string;
|
|
52
|
+
}) => void;
|
|
48
53
|
};
|
|
49
54
|
/** Did you mean, with the index's didYouMean setting: the query the results are for when typo tolerance
|
|
50
55
|
* corrected some words (`applied`, "Showing results for"), or, when nothing matched, a query that
|
|
@@ -76,7 +76,7 @@ export function useSearchBox() {
|
|
|
76
76
|
return { query: state.q, setQuery, clear: () => setQuery(""), isPending: pending };
|
|
77
77
|
}
|
|
78
78
|
export function useHits() {
|
|
79
|
-
const [{ results, pending, error }] = useSnapshot();
|
|
79
|
+
const [{ results, pending, error }, store] = useSnapshot();
|
|
80
80
|
return {
|
|
81
81
|
hits: results?.hits ?? [],
|
|
82
82
|
total: results?.response.total ?? 0,
|
|
@@ -92,6 +92,9 @@ export function useHits() {
|
|
|
92
92
|
isLoaded: results !== null,
|
|
93
93
|
isPending: pending,
|
|
94
94
|
error,
|
|
95
|
+
/** Sends a click event for a hit, with its place and the query, for click-through in Analytics.
|
|
96
|
+
* Hits does it for links in a hit; call it from a hit rendered without a link. */
|
|
97
|
+
click: (hit) => store.click(hit.id),
|
|
95
98
|
};
|
|
96
99
|
}
|
|
97
100
|
/** Did you mean, with the index's didYouMean setting: the query the results are for when typo tolerance
|
package/dist/react/store.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export declare class SearchStore<T = unknown> {
|
|
|
55
55
|
/** A new client or config, from changed props. */
|
|
56
56
|
configure(client: SearchBrowserClient, config: SearchConfig): void;
|
|
57
57
|
get settings(): SearchConfig;
|
|
58
|
+
/** Sends a click on a result: its place in the results, from 1, and the query they're for. */
|
|
59
|
+
click(objectID: string): void;
|
|
58
60
|
/** Searches unless the results already answer the current state; with a state, such as the
|
|
59
61
|
* URL's, adopts it first. */
|
|
60
62
|
start(state?: SearchState): void;
|
package/dist/react/store.js
CHANGED
|
@@ -58,6 +58,13 @@ export class SearchStore {
|
|
|
58
58
|
this.search(false);
|
|
59
59
|
}
|
|
60
60
|
get settings() { return this.config; }
|
|
61
|
+
/** Sends a click on a result: its place in the results, from 1, and the query they're for. */
|
|
62
|
+
click(objectID) {
|
|
63
|
+
const results = this.snapshot.results;
|
|
64
|
+
const index = results ? results.hits.findIndex((hit) => hit.id === objectID) : -1;
|
|
65
|
+
this.client.events.click(objectID, { query: results?.request.q ?? "",
|
|
66
|
+
...(index >= 0 ? { position: (results.request.offset ?? 0) + index + 1 } : {}) });
|
|
67
|
+
}
|
|
61
68
|
/** Searches unless the results already answer the current state; with a state, such as the
|
|
62
69
|
* URL's, adopts it first. */
|
|
63
70
|
start(state) {
|
package/dist/react/styles.css
CHANGED
|
@@ -163,11 +163,19 @@
|
|
|
163
163
|
:where([data-findlane-recommendations] [data-findlane-hits]) {
|
|
164
164
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
165
165
|
}
|
|
166
|
-
:where([data-findlane-recommendations] [data-findlane-hits] > li)
|
|
166
|
+
:where([data-findlane-recommendations] [data-findlane-hits] > li),
|
|
167
|
+
:where([data-findlane-recommendations] [data-findlane-link]) {
|
|
167
168
|
display: grid;
|
|
168
169
|
gap: 6px;
|
|
169
170
|
align-content: start;
|
|
170
171
|
}
|
|
172
|
+
:where([data-findlane-recommendations] [data-findlane-link]) {
|
|
173
|
+
color: inherit;
|
|
174
|
+
text-decoration: none;
|
|
175
|
+
}
|
|
176
|
+
:where([data-findlane-recommendations] [data-findlane-link]:hover [data-findlane-title]) {
|
|
177
|
+
text-decoration: underline;
|
|
178
|
+
}
|
|
171
179
|
:where([data-findlane-recommendations] [data-findlane-image]) {
|
|
172
180
|
width: 100%;
|
|
173
181
|
height: auto;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type HTMLAttributes, type InputHTMLAttributes } from "react";
|
|
2
2
|
import type { Hit } from "./state.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type ClientOptions } from "./client.js";
|
|
4
4
|
export type AutocompleteOptions<T> = ClientOptions & {
|
|
5
5
|
/** Suggestions to show: 1–50 (default 6). */
|
|
6
6
|
limit?: number;
|
|
@@ -28,7 +28,9 @@ export type AutocompleteOptions<T> = ClientOptions & {
|
|
|
28
28
|
/** A hit's address from a template; links to other schemes, such as javascript:, are refused. */
|
|
29
29
|
export declare function fillTemplate<T>(template: string, hit: Hit<T>): string | undefined;
|
|
30
30
|
/** A search box with suggestions, as the WAI-ARIA combobox pattern: the hook keeps the state and
|
|
31
|
-
* the prop getters give the input, list, and options their attributes and events.
|
|
31
|
+
* the prop getters give the input, list, and options their attributes and events. Choosing a
|
|
32
|
+
* suggestion sends a click event with its place and the query, for click-through in Analytics (the
|
|
33
|
+
* client's `events` option turns them off, or holds them until consent). */
|
|
32
34
|
export declare function useAutocomplete<T = Record<string, unknown>>(options?: AutocompleteOptions<T>): {
|
|
33
35
|
query: string;
|
|
34
36
|
setQuery: (value: string) => void;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useEffect, useId, useState, } from "react";
|
|
3
3
|
import { valuesAt } from "./highlight.js";
|
|
4
4
|
import { useSearch } from "./use-search.js";
|
|
5
|
+
import { useClient } from "./client.js";
|
|
5
6
|
/** A hit's address from a template; links to other schemes, such as javascript:, are refused. */
|
|
6
7
|
export function fillTemplate(template, hit) {
|
|
7
8
|
const value = (field) => (field === "objectID" ? hit.id : valuesAt(hit.record, field)[0] ?? "");
|
|
@@ -10,7 +11,9 @@ export function fillTemplate(template, hit) {
|
|
|
10
11
|
return /^[a-z][a-z\d+.-]*:/i.test(href.trim()) && !/^https?:/i.test(href.trim()) ? undefined : href || undefined;
|
|
11
12
|
}
|
|
12
13
|
/** A search box with suggestions, as the WAI-ARIA combobox pattern: the hook keeps the state and
|
|
13
|
-
* the prop getters give the input, list, and options their attributes and events.
|
|
14
|
+
* the prop getters give the input, list, and options their attributes and events. Choosing a
|
|
15
|
+
* suggestion sends a click event with its place and the query, for click-through in Analytics (the
|
|
16
|
+
* client's `events` option turns them off, or holds them until consent). */
|
|
14
17
|
export function useAutocomplete(options = {}) {
|
|
15
18
|
const { limit = 6, fields, filters, sort, minLength = 1, debounceMs, hrefTemplate, onSelect, onSubmit } = options;
|
|
16
19
|
const getHref = options.getHref ?? (hrefTemplate ? (hit) => fillTemplate(hrefTemplate, hit) : undefined);
|
|
@@ -33,6 +36,7 @@ export function useAutocomplete(options = {}) {
|
|
|
33
36
|
const search = useSearch(request, {
|
|
34
37
|
publicId: options.publicId, baseUrl: options.baseUrl, client: options.client, debounceMs,
|
|
35
38
|
});
|
|
39
|
+
const client = useClient({ publicId: options.publicId, baseUrl: options.baseUrl, client: options.client });
|
|
36
40
|
const hits = search.data?.hits ?? [];
|
|
37
41
|
// Open once there's something to show: suggestions, "no results", or an error.
|
|
38
42
|
const isOpen = focused && !dismissed && request !== null && (search.data !== null || search.error !== null);
|
|
@@ -49,7 +53,12 @@ export function useAutocomplete(options = {}) {
|
|
|
49
53
|
setDismissed(true);
|
|
50
54
|
setActiveIndex(-1);
|
|
51
55
|
};
|
|
56
|
+
const clicked = (hit) => {
|
|
57
|
+
const index = hits.indexOf(hit);
|
|
58
|
+
client.events.click(hit.id, { query, ...(index >= 0 ? { position: index + 1 } : {}) });
|
|
59
|
+
};
|
|
52
60
|
const select = (hit) => {
|
|
61
|
+
clicked(hit);
|
|
53
62
|
close();
|
|
54
63
|
if (onSelect)
|
|
55
64
|
return onSelect(hit);
|
|
@@ -158,16 +167,20 @@ export function useAutocomplete(options = {}) {
|
|
|
158
167
|
onClick: (event) => {
|
|
159
168
|
props.onClick?.(event);
|
|
160
169
|
const hit = hits[index];
|
|
161
|
-
|
|
162
|
-
if (event.defaultPrevented || !hit || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
170
|
+
if (event.defaultPrevented || !hit)
|
|
163
171
|
return;
|
|
172
|
+
// A link opened in a new tab or window is left to the browser.
|
|
173
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
174
|
+
return clicked(hit);
|
|
164
175
|
// A link without onSelect is followed as usual.
|
|
165
176
|
if (onSelect || !event.currentTarget.href) {
|
|
166
177
|
event.preventDefault();
|
|
167
178
|
select(hit);
|
|
168
179
|
}
|
|
169
|
-
else
|
|
180
|
+
else {
|
|
181
|
+
clicked(hit);
|
|
170
182
|
close();
|
|
183
|
+
}
|
|
171
184
|
},
|
|
172
185
|
}),
|
|
173
186
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@findlane/browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|