@skawr/search 0.4.0 → 0.4.1
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 +69 -97
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -3,40 +3,39 @@ interface SkawrSearchConfig {
|
|
|
3
3
|
baseUrl?: string;
|
|
4
4
|
timeout?: number;
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Mark this client as a preview surface: a demo, a staging page, an internal
|
|
7
|
+
* tool — anywhere that is not your live storefront.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Leave
|
|
9
|
+
* Preview traffic is excluded from the usage signals we derive from real
|
|
10
|
+
* storefront searches, so trying things out does not look like customer
|
|
11
|
+
* activity. Leave unset in production, which is the default.
|
|
12
12
|
*/
|
|
13
13
|
preview?: boolean;
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
* always done.
|
|
15
|
+
* Let a shopper's own activity influence their result order. Defaults to true.
|
|
17
16
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* — until 0.4.0 it was hardcoded and there was no way to decline.
|
|
17
|
+
* Set false to turn personalization off for every request from this client.
|
|
18
|
+
* Results are then the same for everyone.
|
|
21
19
|
*/
|
|
22
20
|
personalization?: boolean;
|
|
23
21
|
/**
|
|
24
|
-
*
|
|
25
|
-
* one and persist it in `localStorage`, as this SDK has always done.
|
|
22
|
+
* Whether to keep an anonymous shopper id in `localStorage`.
|
|
26
23
|
*
|
|
27
|
-
* `'
|
|
28
|
-
*
|
|
29
|
-
*
|
|
24
|
+
* `'local'` (default) generates one on first use and reuses it, which is what
|
|
25
|
+
* personalization and consistent pagination need. `'none'` stores nothing and
|
|
26
|
+
* sends no id — choose it if you would rather not persist anything in your
|
|
27
|
+
* visitors' browsers, and expect less relevant ordering in exchange.
|
|
28
|
+
*
|
|
29
|
+
* You can always supply your own id per request via `anonymous_id`.
|
|
30
30
|
*/
|
|
31
31
|
identity?: 'local' | 'none';
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
34
|
* Per-call cancellation and deadline, accepted by every request method.
|
|
35
35
|
*
|
|
36
|
-
* Separate from the client's
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* second client to express that would mint a second anonymous id.
|
|
36
|
+
* Separate from the client's `timeout` so one client can make calls with
|
|
37
|
+
* different deadlines — for example a short one for a fast first pass and a
|
|
38
|
+
* longer one for a refinement — without constructing a second client.
|
|
40
39
|
*/
|
|
41
40
|
interface RequestControl {
|
|
42
41
|
/**
|
|
@@ -68,9 +67,11 @@ interface SearchOptions extends RequestControl {
|
|
|
68
67
|
*/
|
|
69
68
|
cursor?: string;
|
|
70
69
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
70
|
+
* Groups one shopper's paged requests, so page 2 continues page 1 instead of
|
|
71
|
+
* repeating or skipping products.
|
|
72
|
+
*
|
|
73
|
+
* Defaults to the SDK's anonymous id. Set it to your own value — a signed-in
|
|
74
|
+
* user id — if you already have one.
|
|
74
75
|
*/
|
|
75
76
|
shopper_id?: string;
|
|
76
77
|
sort_by?: SortBy;
|
|
@@ -93,29 +94,26 @@ interface SearchOptions extends RequestControl {
|
|
|
93
94
|
/** Anonymous shopper ID for personalization. Generated and persisted automatically by the SDK. */
|
|
94
95
|
anonymous_id?: string;
|
|
95
96
|
/**
|
|
96
|
-
* Run the
|
|
97
|
+
* Run the reranking stage. Omit (or `true`) for a normal search.
|
|
97
98
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* its time on the refinement rather than on the answer.
|
|
99
|
+
* A search has two stages: retrieval, which is fast, and reranking, which
|
|
100
|
+
* improves the order and takes considerably longer. By default you wait for
|
|
101
|
+
* both.
|
|
102
102
|
*
|
|
103
|
-
* `rerank: false`
|
|
104
|
-
* `search_id
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* behaves exactly as before.
|
|
103
|
+
* Set `rerank: false` to get retrieval-order results straight away, together
|
|
104
|
+
* with a `search_id`. Pass that id to {@link SearchOptions.search_id} on a
|
|
105
|
+
* second call to fetch the reranked order and update what you rendered. This
|
|
106
|
+
* is optional — ignore it and you get a single call, as before.
|
|
108
107
|
*/
|
|
109
108
|
rerank?: boolean;
|
|
110
109
|
/**
|
|
111
|
-
* The `search_id`
|
|
112
|
-
*
|
|
113
|
-
* ranked as two unrelated searches.
|
|
110
|
+
* The `search_id` returned by a `rerank: false` call, passed back here so both
|
|
111
|
+
* calls are treated as one search rather than two.
|
|
114
112
|
*/
|
|
115
113
|
search_id?: string;
|
|
116
114
|
}
|
|
117
115
|
/**
|
|
118
|
-
* Why a result matched, as the API sends it
|
|
116
|
+
* Why a result matched, as the API sends it.
|
|
119
117
|
*
|
|
120
118
|
* Structured, not a rendered phrase: `{type, field, term}` for a computed
|
|
121
119
|
* reason and `{type: "semantic", text}` for the model-authored one. A
|
|
@@ -149,14 +147,10 @@ interface SearchResult {
|
|
|
149
147
|
highlighted_title?: string;
|
|
150
148
|
highlighted_description?: string;
|
|
151
149
|
/**
|
|
152
|
-
* Why this matched.
|
|
153
|
-
* is the default in production.
|
|
150
|
+
* Why this result matched. Render them with {@link toChips}.
|
|
154
151
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* field name and its shape without the SDK saying either. Typing it is what
|
|
158
|
-
* makes "show why it matched" a thing the SDK offers rather than a thing you
|
|
159
|
-
* have to already know about.
|
|
152
|
+
* Present when match reasons are enabled for your account, which is the
|
|
153
|
+
* default.
|
|
160
154
|
*/
|
|
161
155
|
match_reasons?: MatchReason[];
|
|
162
156
|
/** True when the query matched this product's text literally. */
|
|
@@ -179,9 +173,9 @@ interface SearchResponse {
|
|
|
179
173
|
/** How many results are pageable. NOT how many matched — see `matched_count`. */
|
|
180
174
|
total_results: number;
|
|
181
175
|
/**
|
|
182
|
-
* How many documents matched
|
|
183
|
-
*
|
|
184
|
-
*
|
|
176
|
+
* How many documents matched across the whole index — usually a much larger
|
|
177
|
+
* number than `total_results`, which counts only what you can page through.
|
|
178
|
+
* Null when it was not computed for this search.
|
|
185
179
|
*/
|
|
186
180
|
matched_count?: number | null;
|
|
187
181
|
page_result_count?: number;
|
|
@@ -214,16 +208,10 @@ interface SearchResponse {
|
|
|
214
208
|
personalized?: boolean;
|
|
215
209
|
show_demand_filter?: boolean;
|
|
216
210
|
/**
|
|
217
|
-
*
|
|
211
|
+
* Fields added to the API in future reach you without an SDK upgrade.
|
|
218
212
|
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* at the type boundary and could not be reached even by casting. skawr-web
|
|
222
|
-
* hit the identical bug with a closed product type and lost `match_reasons`
|
|
223
|
-
* for months: nothing failed, the data simply never arrived.
|
|
224
|
-
*
|
|
225
|
-
* Open means a new server field reaches consumers the day it ships, and a
|
|
226
|
-
* missing renderer is a visible gap rather than silent data loss.
|
|
213
|
+
* The named fields above are the documented ones; anything else the API
|
|
214
|
+
* returns is available here rather than being dropped at the type boundary.
|
|
227
215
|
*/
|
|
228
216
|
[key: string]: unknown;
|
|
229
217
|
}
|
|
@@ -277,33 +265,25 @@ declare class SkawrSearch {
|
|
|
277
265
|
/**
|
|
278
266
|
* Why a search failed, in terms a UI can act on.
|
|
279
267
|
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* and 401/403 as "do not mount and warn"; skawr-web's marketplace client turns
|
|
283
|
-
* any failure into an `unavailable` flag so it can say "we could not answer"
|
|
284
|
-
* rather than "this catalogue is empty". Two consumers, two hand-rolled
|
|
285
|
-
* mappings of the same five cases — so the mapping belongs here.
|
|
286
|
-
*
|
|
287
|
-
* The vocabulary is the widget's, not a new one: `subscription-expired` and
|
|
288
|
-
* `invalid-key` already drive real behaviour there, and renaming them would
|
|
289
|
-
* have bought nothing but a migration.
|
|
268
|
+
* Branch on {@link SearchError.kind} rather than on `status`: a cancelled
|
|
269
|
+
* request, a timeout and a network failure never had an HTTP status at all.
|
|
290
270
|
*
|
|
291
|
-
* An empty result set is deliberately NOT an error. "
|
|
292
|
-
*
|
|
293
|
-
*
|
|
271
|
+
* An empty result set is deliberately NOT an error. "Nothing in this catalogue
|
|
272
|
+
* matched" is a normal answer and arrives as `results: []`; everything here
|
|
273
|
+
* means the search could not be answered.
|
|
294
274
|
*/
|
|
295
275
|
type ErrorKind =
|
|
296
|
-
/** 402 —
|
|
276
|
+
/** 402 — this account's subscription is not active. */
|
|
297
277
|
'subscription-expired'
|
|
298
278
|
/** 401/403 — the key is wrong, revoked, or not allowed from this origin. */
|
|
299
279
|
| 'invalid-key'
|
|
300
|
-
/** 429 —
|
|
280
|
+
/** 429 — too many requests. Backing off and retrying may succeed. */
|
|
301
281
|
| 'rate-limited'
|
|
302
282
|
/** The request exceeded its timeout. Retrying is reasonable. */
|
|
303
283
|
| 'timeout'
|
|
304
284
|
/** The caller aborted it — a superseded keystroke, a closed page. Not a fault. */
|
|
305
285
|
| 'aborted'
|
|
306
|
-
/** 5xx
|
|
286
|
+
/** 5xx — a server-side failure. A single retry is reasonable. */
|
|
307
287
|
| 'server-error'
|
|
308
288
|
/** Never reached the server at all: DNS, offline, CORS, TLS. */
|
|
309
289
|
| 'network'
|
|
@@ -319,43 +299,35 @@ declare class SearchError extends Error {
|
|
|
319
299
|
readonly kind: ErrorKind;
|
|
320
300
|
constructor(message: string, status: number, detail?: string, kind?: ErrorKind);
|
|
321
301
|
/**
|
|
322
|
-
* True when
|
|
302
|
+
* True when you aborted this request yourself.
|
|
323
303
|
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* each of them would be both wrong and alarming.
|
|
304
|
+
* A debounced search box cancels the request for every keystroke but the
|
|
305
|
+
* last; those are expected and should be ignored, not shown to the shopper.
|
|
327
306
|
*/
|
|
328
307
|
get isAborted(): boolean;
|
|
329
308
|
}
|
|
330
309
|
|
|
331
310
|
/**
|
|
332
|
-
*
|
|
333
|
-
* (skawr-search#507 / #522 / #528).
|
|
311
|
+
* Turn a result's match reasons into readable chips.
|
|
334
312
|
*
|
|
335
|
-
* The API sends `{ type, field, term }`
|
|
336
|
-
*
|
|
337
|
-
* reason
|
|
338
|
-
*
|
|
339
|
-
* SDK, so every surface that renders results renders the *same* explanation:
|
|
340
|
-
* the marketplace, the hosted store page, the embeddable widget and anything a
|
|
341
|
-
* customer builds. Before this, the only implementation was inside skawr-web,
|
|
342
|
-
* which is why two of our own surfaces shipped bare grids while the marketplace
|
|
343
|
-
* explained itself (skawr-sdks#12).
|
|
313
|
+
* The API sends structured reasons — `{ type, field, term }` for a computed
|
|
314
|
+
* match, `{ type: "semantic", text }` for a model-authored one — and no display
|
|
315
|
+
* string. That keeps the reason translatable and restylable, and leaves the
|
|
316
|
+
* wording to you. This does that wording.
|
|
344
317
|
*
|
|
345
|
-
* No DOM
|
|
346
|
-
*
|
|
347
|
-
* `EN_REASON_LABELS` is the default
|
|
318
|
+
* No DOM and no framework: each chip is a label plus two style flags, and you
|
|
319
|
+
* render them however your UI renders small tags. Pass your own `Strings` map to
|
|
320
|
+
* localize the computed labels; `EN_REASON_LABELS` is the English default.
|
|
348
321
|
*
|
|
349
|
-
* Two rules the
|
|
322
|
+
* Two rules the output holds to:
|
|
350
323
|
*
|
|
351
|
-
* 1. An
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* evidence-backed, which defeats the point of having them.
|
|
324
|
+
* 1. An unrecognised `type` produces no chip rather than a guessed label. The
|
|
325
|
+
* vocabulary is open and more types may be added, and a chip that guessed
|
|
326
|
+
* would be the one that is not backed by evidence.
|
|
355
327
|
*
|
|
356
|
-
* 2. `vector`
|
|
357
|
-
* text. It is the chip that explains a surprising result, so it
|
|
358
|
-
*
|
|
328
|
+
* 2. `vector` states plainly that none of the query's words appear in the
|
|
329
|
+
* product's text. It is the chip that explains a surprising result, so it is
|
|
330
|
+
* styled apart from the confident types rather than dressed up to match them.
|
|
359
331
|
*/
|
|
360
332
|
|
|
361
333
|
interface ReasonChip {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/errors.ts","../src/client.ts","../src/matchReasons.ts"],"names":["kindFromStatus","status","SearchError","message","detail","kind","DEFAULT_BASE_URL","DEFAULT_TIMEOUT","ANON_ID_KEY","generateAnonId","c","r","getOrCreateAnonId","id","defined","obj","out","key","value","SkawrSearch","config","query","options","anonId","params","path","call","url","qs","body","timeout","init","headers","response","error","text","parsed","EN_REASON_LABELS","fieldLabel","field","t","fill","template","values","_","k","toChip","reason","term","q","toChips","reasons"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Why a search failed, in terms a UI can act on.\n *\n * Every consumer so far has had to rebuild this from `status`, because a\n * number is not a decision. The widget classifies 402 as \"unmount silently\"\n * and 401/403 as \"do not mount and warn\"; skawr-web's marketplace client turns\n * any failure into an `unavailable` flag so it can say \"we could not answer\"\n * rather than \"this catalogue is empty\". Two consumers, two hand-rolled\n * mappings of the same five cases — so the mapping belongs here.\n *\n * The vocabulary is the widget's, not a new one: `subscription-expired` and\n * `invalid-key` already drive real behaviour there, and renaming them would\n * have bought nothing but a migration.\n *\n * An empty result set is deliberately NOT an error. \"This catalogue has no\n * matching products\" is a statement about the merchant's data and belongs in\n * `results: []`; everything here is a statement about us.\n */\nexport type ErrorKind =\n /** 402 — the account's subscription lapsed. Stop asking; say nothing to shoppers. */\n | 'subscription-expired'\n /** 401/403 — the key is wrong, revoked, or not allowed from this origin. */\n | 'invalid-key'\n /** 429 — rate limit or a spent preview allowance. Backing off may help. */\n | 'rate-limited'\n /** The request exceeded its timeout. Retrying is reasonable. */\n | 'timeout'\n /** The caller aborted it — a superseded keystroke, a closed page. Not a fault. */\n | 'aborted'\n /** 5xx. Ours to fix; one retry, then a gentle message. */\n | 'server-error'\n /** Never reached the server at all: DNS, offline, CORS, TLS. */\n | 'network'\n /** A 4xx we have no specific handling for. */\n | 'unknown';\n\n/** HTTP status to the kind a UI branches on. */\nfunction kindFromStatus(status: number): ErrorKind {\n if (status === 402) return 'subscription-expired';\n if (status === 401 || status === 403) return 'invalid-key';\n if (status === 429) return 'rate-limited';\n if (status >= 500) return 'server-error';\n // `status === 0` is this SDK's marker for \"no HTTP response happened\",\n // which the transport sets for a timeout or a network failure and then\n // overrides with the precise kind. Reaching here with 0 means neither, so\n // it is genuinely unknown rather than silently a network error.\n return 'unknown';\n}\n\nexport class SearchError extends Error {\n readonly status: number;\n readonly detail: string;\n /**\n * What kind of failure this is. Prefer branching on this over `status`:\n * `aborted`, `timeout` and `network` never had a status to begin with.\n */\n readonly kind: ErrorKind;\n\n constructor(message: string, status: number, detail?: string, kind?: ErrorKind) {\n super(message);\n this.name = 'SearchError';\n this.status = status;\n this.detail = detail ?? message;\n this.kind = kind ?? kindFromStatus(status);\n }\n\n /**\n * True when the caller abandoned this request, so there is nobody to tell.\n *\n * The case this exists for: a debounced search box aborts the request for\n * every keystroke but the last, and rendering \"search is unavailable\" for\n * each of them would be both wrong and alarming.\n */\n get isAborted(): boolean {\n return this.kind === 'aborted';\n }\n}\n","import { SearchError } from './errors.js';\nimport type {\n RequestControl,\n SkawrSearchConfig,\n SearchOptions,\n SearchResponse,\n SuggestOptions,\n SuggestionsResponse,\n AutocompleteOptions,\n AutocompleteResponse,\n} from './types.js';\n\nconst DEFAULT_BASE_URL = 'https://api.skawr.com';\nconst DEFAULT_TIMEOUT = 10_000;\nconst ANON_ID_KEY = 'skawr_anon_id';\n\n/** Generate a UUID v4 using crypto.randomUUID when available, fallback to Math.random. */\nfunction generateAnonId(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0;\n const v = c === 'x' ? r : (r & 0x3) | 0x8;\n return v.toString(16);\n });\n}\n\n/** Get or create a persistent anonymous ID for this browser/device. */\nfunction getOrCreateAnonId(): string | undefined {\n if (typeof window === 'undefined' || typeof localStorage === 'undefined') return undefined;\n try {\n let id = localStorage.getItem(ANON_ID_KEY);\n if (!id) {\n id = generateAnonId();\n localStorage.setItem(ANON_ID_KEY, id);\n }\n return id;\n } catch {\n // localStorage disabled (private mode, etc.)\n return undefined;\n }\n}\n\n/**\n * Drop keys whose value is undefined.\n *\n * `JSON.stringify` already omits them, so this changes no wire bytes — it is\n * here so the body reads as \"these are the fields the caller set\", and so a\n * future `Object.keys` over the body cannot pick up phantom entries.\n */\nfunction defined<T extends Record<string, unknown>>(obj: T): Partial<T> {\n const out: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(obj)) {\n if (value !== undefined) out[key] = value;\n }\n return out as Partial<T>;\n}\n\nexport class SkawrSearch {\n private readonly baseUrl: string;\n private readonly publicKey: string;\n private readonly timeout: number;\n private readonly preview: boolean;\n private readonly personalization: boolean;\n private readonly identity: 'local' | 'none';\n\n constructor(config: SkawrSearchConfig) {\n if (!config.publicKey) {\n throw new Error('publicKey is required');\n }\n\n this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, '');\n this.publicKey = config.publicKey;\n this.timeout = config.timeout ?? DEFAULT_TIMEOUT;\n this.preview = config.preview ?? false;\n // Both default to today's behaviour. They exist so a site that does not\n // want behavioural personalization, or does not want a persistent id\n // written to its visitors' browsers, can say so — the SDK runs on the\n // customer's domain, and neither choice is ours to make for them.\n this.personalization = config.personalization ?? true;\n this.identity = config.identity ?? 'local';\n }\n\n /** The stored anonymous id, or undefined when identity is off. */\n private anonymousId(): string | undefined {\n return this.identity === 'none' ? undefined : getOrCreateAnonId();\n }\n\n async search(query: string, options?: SearchOptions): Promise<SearchResponse> {\n // One id, two purposes. The indexer keys its served-set on `shopper_id` to\n // make numbered pages dense and de-duplicated; without it a bare ?page=N\n // takes the legacy grid, where each page blends page-specific text hits\n // with a constant vector set — measured at ~20% of slots repeating across\n // adjacent pages, which also means products that should have appeared never\n // do (skawr-sdks#11, skawr-search#542).\n //\n // They are sent as separate fields rather than reused server-side because\n // they mean different things — one identifies a shopper for\n // personalization, the other scopes a pagination session — and collapsing\n // them would tie de-duplication to having personalization enabled.\n const anonId = options?.anonymous_id ?? this.anonymousId();\n\n // Every field is sent only when it has a value. The server owns its own\n // defaults, and an SDK that pins one silently overrides a change to it —\n // which is why `rerank` is absent rather than `true`, and why\n // `enable_personalization` is no longer hardcoded.\n return this.post<SearchResponse>(\n '/api/v1/search',\n {\n query,\n ...defined({\n filters: options?.filters,\n page: options?.page,\n per_page: options?.per_page,\n cursor: options?.cursor,\n sort_by: options?.sort_by,\n highlight_matches: options?.highlight_matches,\n include_suggestions: options?.include_suggestions,\n include_analytics: options?.include_analytics,\n result_format: options?.result_format,\n features: options?.features,\n // Two-phase search (skawr-search#511): `false` asks for the fast\n // retrieval-only pass, and the `search_id` it returns correlates the\n // refine pass to the same query. `true` is the server's default, so\n // it is not sent.\n rerank: options?.rerank === false ? false : undefined,\n search_id: options?.search_id || undefined,\n shopper_id: options?.shopper_id ?? anonId,\n anonymous_id: anonId,\n enable_personalization: this.personalization ? true : undefined,\n }),\n },\n options,\n );\n }\n\n async suggest(query: string, options?: SuggestOptions): Promise<SuggestionsResponse> {\n const params: Record<string, string> = { q: query };\n if (options?.limit !== undefined) params.limit = String(options.limit);\n if (options?.include_trending !== undefined) params.include_trending = String(options.include_trending);\n\n return this.get<SuggestionsResponse>('/api/v1/search/suggestions', params);\n }\n\n async autocomplete(query: string, options?: AutocompleteOptions): Promise<AutocompleteResponse> {\n const params: Record<string, string> = { q: query };\n if (options?.limit !== undefined) params.limit = String(options.limit);\n\n return this.get<AutocompleteResponse>('/api/v1/autocomplete', params);\n }\n\n private async get<T>(\n path: string,\n params?: Record<string, string>,\n call?: RequestControl,\n ): Promise<T> {\n let url = `${this.baseUrl}${path}`;\n if (params) {\n const qs = new URLSearchParams(params).toString();\n if (qs) url += `?${qs}`;\n }\n\n return this.request<T>(url, { method: 'GET' }, call);\n }\n\n private async post<T>(path: string, body: unknown, call?: RequestControl): Promise<T> {\n return this.request<T>(\n `${this.baseUrl}${path}`,\n { method: 'POST', body: JSON.stringify(body) },\n call,\n );\n }\n\n /**\n * The signal for one request: the caller's, this call's timeout, or both.\n *\n * Both matters. A debounced box aborts superseded keystrokes through its own\n * signal, and still wants the timeout for the request it keeps — before this,\n * `AbortSignal.timeout()` was the only signal a request could have, so a\n * caller could not cancel anything.\n *\n * `AbortSignal.any` is the standard combinator and is present everywhere\n * `AbortSignal.timeout` is (both landed together), so a caller that can time\n * out can also combine.\n */\n private signalFor(call?: RequestControl): AbortSignal {\n const timeout = AbortSignal.timeout(call?.timeout ?? this.timeout);\n return call?.signal ? AbortSignal.any([call.signal, timeout]) : timeout;\n }\n\n private async request<T>(url: string, init: RequestInit, call?: RequestControl): Promise<T> {\n const headers: Record<string, string> = {\n 'X-API-Key': this.publicKey,\n 'Accept': 'application/json',\n };\n\n // A preview surface is one that is not the merchant's own storefront: an\n // onboarding playground, a shared store link, a demo. The API records the\n // first search from a real storefront as \"this store is live\"\n // (skawr-search#177/#785), so without this header the first person to open\n // a shared link would flip a merchant's store live on their behalf.\n if (this.preview) {\n headers['X-Skawr-Preview'] = '1';\n }\n\n if (init.body) {\n headers['Content-Type'] = 'application/json';\n }\n\n let response: Response;\n try {\n response = await fetch(url, { ...init, headers, signal: this.signalFor(call) });\n } catch (error) {\n // Both arrive as a DOMException and only `name` tells them apart, which\n // is the whole reason `kind` exists: a caller must be able to ignore its\n // own cancellation without also ignoring a real timeout.\n if (error instanceof DOMException && error.name === 'TimeoutError') {\n throw new SearchError('Request timed out', 0, 'Request timed out', 'timeout');\n }\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new SearchError('Request aborted', 0, 'Request aborted', 'aborted');\n }\n // Anything else from `fetch` never reached the server: offline, DNS, TLS,\n // or a CORS rejection. Wrapped rather than re-thrown so a caller has one\n // error type to handle instead of two.\n throw new SearchError(\n 'Could not reach the search service',\n 0,\n error instanceof Error ? error.message : String(error),\n 'network',\n );\n }\n\n if (!response.ok) {\n const text = await response.text();\n let detail: string;\n try {\n const parsed = JSON.parse(text);\n detail = typeof parsed.detail === 'string' ? parsed.detail : text;\n } catch {\n detail = text || response.statusText;\n }\n throw new SearchError(detail, response.status, detail);\n }\n\n return (await response.json()) as T;\n }\n}\n","/**\n * Turning a match reason into a chip a shopper can read\n * (skawr-search#507 / #522 / #528).\n *\n * The API sends `{ type, field, term }` (computed) or `{ type: \"semantic\", text }`\n * (model-authored) and no display string. That is the right shape — a structured\n * reason survives translation and restyling, a pre-rendered English phrase does\n * not — but it means the wording has to live somewhere. It lives here, in the\n * SDK, so every surface that renders results renders the *same* explanation:\n * the marketplace, the hosted store page, the embeddable widget and anything a\n * customer builds. Before this, the only implementation was inside skawr-web,\n * which is why two of our own surfaces shipped bare grids while the marketplace\n * explained itself (skawr-sdks#12).\n *\n * No DOM, no framework: this returns text and two style flags, and the caller\n * draws them. Callers pass a `Strings` map for the computed labels (localized);\n * `EN_REASON_LABELS` is the default for surfaces without their own i18n.\n *\n * Two rules the copy has to hold:\n *\n * 1. An unknown `type` renders as nothing rather than as something invented.\n * The vocabulary is open on purpose and more types may follow. A chip that\n * guessed at an unfamiliar type would be the one kind of reason that is not\n * evidence-backed, which defeats the point of having them.\n *\n * 2. `vector` says plainly that nothing in the query appeared in the product's\n * text. It is the chip that explains a surprising result, so it must not be\n * dressed up to look as confident as a literal match.\n */\n\nimport type { MatchReason } from './types.js';\n\nexport interface ReasonChip {\n /** Text to display, already localized. */\n label: string;\n /** True for the reason types that carry the accent treatment. */\n tinted: boolean;\n /** True for `vector`, which is styled apart from the confident types. */\n soft: boolean;\n}\n\n/**\n * Label templates, keyed by name. Typed loosely on purpose: a caller's i18n\n * module is a `Record<string, string>` and pretending otherwise would only move\n * the looseness out of sight. Missing keys render as an empty span of text\n * rather than throwing — `EN_REASON_LABELS` below names every key in use.\n */\nexport type Strings = Record<string, string>;\n\n/** Default English labels, for a surface with no localized `Strings`. */\nexport const EN_REASON_LABELS: Strings = {\n reasonLiteral: '{t} · {f}',\n reasonVariant: '{t} · {f} · word form',\n reasonSynonym: '{t} — synonym of “{q}”',\n reasonVector: 'matched by meaning',\n fieldTitle: 'title',\n fieldDescription: 'description',\n};\n\n/**\n * Field names the API uses, in the reader's language. An unknown field passes\n * through as-is rather than being dropped — a field we have not translated is\n * still more useful shown than hidden.\n */\nfunction fieldLabel(field: string | undefined, t: Strings): string {\n if (field === 'title') return t.fieldTitle;\n if (field === 'description') return t.fieldDescription;\n return field ?? '';\n}\n\nfunction fill(template: string, values: Record<string, string>): string {\n return template.replace(/\\{(\\w)\\}/g, (_, k: string) => values[k] ?? '');\n}\n\n/**\n * One chip, or null when the reason cannot be rendered honestly.\n *\n * `query` is needed only by the synonym chip, which names the word the shopper\n * actually typed so the cross-language jump is legible: seeing `جوال` next to\n * a search for `phone` is confusing without it.\n */\nexport function toChip(\n reason: MatchReason,\n t: Strings = EN_REASON_LABELS,\n query = '',\n): ReasonChip | null {\n const term = (reason.term ?? '').trim();\n const field = fieldLabel(reason.field, t);\n\n switch (reason.type) {\n case 'literal':\n if (!term) return null;\n return { label: fill(t.reasonLiteral, { t: term, f: field }), tinted: false, soft: false };\n\n case 'variant':\n if (!term) return null;\n return { label: fill(t.reasonVariant, { t: term, f: field }), tinted: false, soft: false };\n\n case 'synonym': {\n if (!term) return null;\n // The first word of the query is the one that most likely produced the\n // synonym edge. Imperfect on a multi-word query, and better than naming\n // the whole query, which reads as if all of it matched.\n const q = query.trim().split(/\\s+/)[0] ?? '';\n return { label: fill(t.reasonSynonym, { t: term, q }), tinted: true, soft: false };\n }\n\n case 'vector':\n return { label: t.reasonVector, tinted: false, soft: true };\n\n case 'semantic': {\n // Model-authored reason (skawr-search#522): already a full sentence in the\n // shopper's language, so it is shown verbatim rather than templated. Tinted\n // like the confident types — it is an affirmative \"why this matched\", not\n // the apologetic vector note. Empty text renders nothing rather than a\n // blank chip.\n const text = (reason.text ?? '').trim();\n if (!text) return null;\n return { label: text, tinted: true, soft: false };\n }\n\n default:\n // An unfamiliar type renders as nothing rather than as a guess.\n return null;\n }\n}\n\n/** Every renderable chip for one result, in the order the API ranked them. */\nexport function toChips(\n reasons: MatchReason[] | undefined,\n t: Strings = EN_REASON_LABELS,\n query = '',\n): ReasonChip[] {\n if (!Array.isArray(reasons)) return [];\n return reasons\n .map((r) => toChip(r, t, query))\n .filter((c): c is ReasonChip => c !== null);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts","../src/client.ts","../src/matchReasons.ts"],"names":["kindFromStatus","status","SearchError","message","detail","kind","DEFAULT_BASE_URL","DEFAULT_TIMEOUT","ANON_ID_KEY","generateAnonId","c","r","getOrCreateAnonId","id","defined","obj","out","key","value","SkawrSearch","config","query","options","anonId","params","path","call","url","qs","body","timeout","init","headers","response","error","text","parsed","EN_REASON_LABELS","fieldLabel","field","t","fill","template","values","_","k","toChip","reason","term","q","toChips","reasons"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Why a search failed, in terms a UI can act on.\n *\n * Branch on {@link SearchError.kind} rather than on `status`: a cancelled\n * request, a timeout and a network failure never had an HTTP status at all.\n *\n * An empty result set is deliberately NOT an error. \"Nothing in this catalogue\n * matched\" is a normal answer and arrives as `results: []`; everything here\n * means the search could not be answered.\n */\nexport type ErrorKind =\n /** 402 — this account's subscription is not active. */\n | 'subscription-expired'\n /** 401/403 — the key is wrong, revoked, or not allowed from this origin. */\n | 'invalid-key'\n /** 429 — too many requests. Backing off and retrying may succeed. */\n | 'rate-limited'\n /** The request exceeded its timeout. Retrying is reasonable. */\n | 'timeout'\n /** The caller aborted it — a superseded keystroke, a closed page. Not a fault. */\n | 'aborted'\n /** 5xx — a server-side failure. A single retry is reasonable. */\n | 'server-error'\n /** Never reached the server at all: DNS, offline, CORS, TLS. */\n | 'network'\n /** A 4xx we have no specific handling for. */\n | 'unknown';\n\n/** HTTP status to the kind a UI branches on. */\nfunction kindFromStatus(status: number): ErrorKind {\n if (status === 402) return 'subscription-expired';\n if (status === 401 || status === 403) return 'invalid-key';\n if (status === 429) return 'rate-limited';\n if (status >= 500) return 'server-error';\n // `status === 0` marks \"no HTTP response happened\". The transport sets the\n // precise kind for those cases, so reaching here with 0 is genuinely\n // unknown rather than silently a network error.\n return 'unknown';\n}\n\nexport class SearchError extends Error {\n readonly status: number;\n readonly detail: string;\n /**\n * What kind of failure this is. Prefer branching on this over `status`:\n * `aborted`, `timeout` and `network` never had a status to begin with.\n */\n readonly kind: ErrorKind;\n\n constructor(message: string, status: number, detail?: string, kind?: ErrorKind) {\n super(message);\n this.name = 'SearchError';\n this.status = status;\n this.detail = detail ?? message;\n this.kind = kind ?? kindFromStatus(status);\n }\n\n /**\n * True when you aborted this request yourself.\n *\n * A debounced search box cancels the request for every keystroke but the\n * last; those are expected and should be ignored, not shown to the shopper.\n */\n get isAborted(): boolean {\n return this.kind === 'aborted';\n }\n}\n","import { SearchError } from './errors.js';\nimport type {\n RequestControl,\n SkawrSearchConfig,\n SearchOptions,\n SearchResponse,\n SuggestOptions,\n SuggestionsResponse,\n AutocompleteOptions,\n AutocompleteResponse,\n} from './types.js';\n\nconst DEFAULT_BASE_URL = 'https://api.skawr.com';\nconst DEFAULT_TIMEOUT = 10_000;\nconst ANON_ID_KEY = 'skawr_anon_id';\n\n/** Generate a UUID v4 using crypto.randomUUID when available, fallback to Math.random. */\nfunction generateAnonId(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0;\n const v = c === 'x' ? r : (r & 0x3) | 0x8;\n return v.toString(16);\n });\n}\n\n/** Get or create a persistent anonymous ID for this browser/device. */\nfunction getOrCreateAnonId(): string | undefined {\n if (typeof window === 'undefined' || typeof localStorage === 'undefined') return undefined;\n try {\n let id = localStorage.getItem(ANON_ID_KEY);\n if (!id) {\n id = generateAnonId();\n localStorage.setItem(ANON_ID_KEY, id);\n }\n return id;\n } catch {\n // localStorage disabled (private mode, etc.)\n return undefined;\n }\n}\n\n/**\n * Drop keys whose value is undefined.\n *\n * `JSON.stringify` already omits them, so this changes no wire bytes — it is\n * here so the body reads as \"these are the fields the caller set\", and so a\n * future `Object.keys` over the body cannot pick up phantom entries.\n */\nfunction defined<T extends Record<string, unknown>>(obj: T): Partial<T> {\n const out: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(obj)) {\n if (value !== undefined) out[key] = value;\n }\n return out as Partial<T>;\n}\n\nexport class SkawrSearch {\n private readonly baseUrl: string;\n private readonly publicKey: string;\n private readonly timeout: number;\n private readonly preview: boolean;\n private readonly personalization: boolean;\n private readonly identity: 'local' | 'none';\n\n constructor(config: SkawrSearchConfig) {\n if (!config.publicKey) {\n throw new Error('publicKey is required');\n }\n\n this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, '');\n this.publicKey = config.publicKey;\n this.timeout = config.timeout ?? DEFAULT_TIMEOUT;\n this.preview = config.preview ?? false;\n // Both default to today's behaviour. They exist so a site that does not\n // want behavioural personalization, or does not want a persistent id\n // written to its visitors' browsers, can say so — the SDK runs on the\n // customer's domain, and neither choice is ours to make for them.\n this.personalization = config.personalization ?? true;\n this.identity = config.identity ?? 'local';\n }\n\n /** The stored anonymous id, or undefined when identity is off. */\n private anonymousId(): string | undefined {\n return this.identity === 'none' ? undefined : getOrCreateAnonId();\n }\n\n async search(query: string, options?: SearchOptions): Promise<SearchResponse> {\n // The same id serves two distinct purposes and is sent as two fields:\n // `anonymous_id` identifies a shopper for personalization, `shopper_id`\n // groups their paged requests so pages stay consistent. Callers can set\n // either independently.\n const anonId = options?.anonymous_id ?? this.anonymousId();\n\n // Only fields the caller actually set are sent, so server-side defaults\n // remain in effect for everything else.\n return this.post<SearchResponse>(\n '/api/v1/search',\n {\n query,\n ...defined({\n filters: options?.filters,\n page: options?.page,\n per_page: options?.per_page,\n cursor: options?.cursor,\n sort_by: options?.sort_by,\n highlight_matches: options?.highlight_matches,\n include_suggestions: options?.include_suggestions,\n include_analytics: options?.include_analytics,\n result_format: options?.result_format,\n features: options?.features,\n // `false` asks for the fast retrieval-only pass; the `search_id` it\n // returns ties the follow-up call to the same search. `true` is the\n // server's default, so it is not sent.\n rerank: options?.rerank === false ? false : undefined,\n search_id: options?.search_id || undefined,\n shopper_id: options?.shopper_id ?? anonId,\n anonymous_id: anonId,\n enable_personalization: this.personalization ? true : undefined,\n }),\n },\n options,\n );\n }\n\n async suggest(query: string, options?: SuggestOptions): Promise<SuggestionsResponse> {\n const params: Record<string, string> = { q: query };\n if (options?.limit !== undefined) params.limit = String(options.limit);\n if (options?.include_trending !== undefined) params.include_trending = String(options.include_trending);\n\n return this.get<SuggestionsResponse>('/api/v1/search/suggestions', params);\n }\n\n async autocomplete(query: string, options?: AutocompleteOptions): Promise<AutocompleteResponse> {\n const params: Record<string, string> = { q: query };\n if (options?.limit !== undefined) params.limit = String(options.limit);\n\n return this.get<AutocompleteResponse>('/api/v1/autocomplete', params);\n }\n\n private async get<T>(\n path: string,\n params?: Record<string, string>,\n call?: RequestControl,\n ): Promise<T> {\n let url = `${this.baseUrl}${path}`;\n if (params) {\n const qs = new URLSearchParams(params).toString();\n if (qs) url += `?${qs}`;\n }\n\n return this.request<T>(url, { method: 'GET' }, call);\n }\n\n private async post<T>(path: string, body: unknown, call?: RequestControl): Promise<T> {\n return this.request<T>(\n `${this.baseUrl}${path}`,\n { method: 'POST', body: JSON.stringify(body) },\n call,\n );\n }\n\n /**\n * The signal for one request: the caller's, this call's timeout, or both.\n *\n * Both matters. A debounced box aborts superseded keystrokes through its own\n * signal, and still wants the timeout for the request it keeps — before this,\n * `AbortSignal.timeout()` was the only signal a request could have, so a\n * caller could not cancel anything.\n *\n * `AbortSignal.any` is the standard combinator and is present everywhere\n * `AbortSignal.timeout` is (both landed together), so a caller that can time\n * out can also combine.\n */\n private signalFor(call?: RequestControl): AbortSignal {\n const timeout = AbortSignal.timeout(call?.timeout ?? this.timeout);\n return call?.signal ? AbortSignal.any([call.signal, timeout]) : timeout;\n }\n\n private async request<T>(url: string, init: RequestInit, call?: RequestControl): Promise<T> {\n const headers: Record<string, string> = {\n 'X-API-Key': this.publicKey,\n 'Accept': 'application/json',\n };\n\n // Marks the request as coming from a preview surface rather than a live\n // storefront. See `SkawrSearchConfig.preview`.\n if (this.preview) {\n headers['X-Skawr-Preview'] = '1';\n }\n\n if (init.body) {\n headers['Content-Type'] = 'application/json';\n }\n\n let response: Response;\n try {\n response = await fetch(url, { ...init, headers, signal: this.signalFor(call) });\n } catch (error) {\n // Both arrive as a DOMException and only `name` tells them apart, which\n // is the whole reason `kind` exists: a caller must be able to ignore its\n // own cancellation without also ignoring a real timeout.\n if (error instanceof DOMException && error.name === 'TimeoutError') {\n throw new SearchError('Request timed out', 0, 'Request timed out', 'timeout');\n }\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new SearchError('Request aborted', 0, 'Request aborted', 'aborted');\n }\n // Anything else from `fetch` never reached the server: offline, DNS, TLS,\n // or a CORS rejection. Wrapped rather than re-thrown so a caller has one\n // error type to handle instead of two.\n throw new SearchError(\n 'Could not reach the search service',\n 0,\n error instanceof Error ? error.message : String(error),\n 'network',\n );\n }\n\n if (!response.ok) {\n const text = await response.text();\n let detail: string;\n try {\n const parsed = JSON.parse(text);\n detail = typeof parsed.detail === 'string' ? parsed.detail : text;\n } catch {\n detail = text || response.statusText;\n }\n throw new SearchError(detail, response.status, detail);\n }\n\n return (await response.json()) as T;\n }\n}\n","/**\n * Turn a result's match reasons into readable chips.\n *\n * The API sends structured reasons — `{ type, field, term }` for a computed\n * match, `{ type: \"semantic\", text }` for a model-authored one — and no display\n * string. That keeps the reason translatable and restylable, and leaves the\n * wording to you. This does that wording.\n *\n * No DOM and no framework: each chip is a label plus two style flags, and you\n * render them however your UI renders small tags. Pass your own `Strings` map to\n * localize the computed labels; `EN_REASON_LABELS` is the English default.\n *\n * Two rules the output holds to:\n *\n * 1. An unrecognised `type` produces no chip rather than a guessed label. The\n * vocabulary is open and more types may be added, and a chip that guessed\n * would be the one that is not backed by evidence.\n *\n * 2. `vector` states plainly that none of the query's words appear in the\n * product's text. It is the chip that explains a surprising result, so it is\n * styled apart from the confident types rather than dressed up to match them.\n */\nimport type { MatchReason } from './types.js';\n\nexport interface ReasonChip {\n /** Text to display, already localized. */\n label: string;\n /** True for the reason types that carry the accent treatment. */\n tinted: boolean;\n /** True for `vector`, which is styled apart from the confident types. */\n soft: boolean;\n}\n\n/**\n * Label templates, keyed by name. Typed loosely on purpose: a caller's i18n\n * module is a `Record<string, string>` and pretending otherwise would only move\n * the looseness out of sight. Missing keys render as an empty span of text\n * rather than throwing — `EN_REASON_LABELS` below names every key in use.\n */\nexport type Strings = Record<string, string>;\n\n/** Default English labels, for a surface with no localized `Strings`. */\nexport const EN_REASON_LABELS: Strings = {\n reasonLiteral: '{t} · {f}',\n reasonVariant: '{t} · {f} · word form',\n reasonSynonym: '{t} — synonym of “{q}”',\n reasonVector: 'matched by meaning',\n fieldTitle: 'title',\n fieldDescription: 'description',\n};\n\n/**\n * Field names the API uses, in the reader's language. An unknown field passes\n * through as-is rather than being dropped — a field we have not translated is\n * still more useful shown than hidden.\n */\nfunction fieldLabel(field: string | undefined, t: Strings): string {\n if (field === 'title') return t.fieldTitle;\n if (field === 'description') return t.fieldDescription;\n return field ?? '';\n}\n\nfunction fill(template: string, values: Record<string, string>): string {\n return template.replace(/\\{(\\w)\\}/g, (_, k: string) => values[k] ?? '');\n}\n\n/**\n * One chip, or null when the reason cannot be rendered honestly.\n *\n * `query` is needed only by the synonym chip, which names the word the shopper\n * actually typed so the cross-language jump is legible: seeing `جوال` next to\n * a search for `phone` is confusing without it.\n */\nexport function toChip(\n reason: MatchReason,\n t: Strings = EN_REASON_LABELS,\n query = '',\n): ReasonChip | null {\n const term = (reason.term ?? '').trim();\n const field = fieldLabel(reason.field, t);\n\n switch (reason.type) {\n case 'literal':\n if (!term) return null;\n return { label: fill(t.reasonLiteral, { t: term, f: field }), tinted: false, soft: false };\n\n case 'variant':\n if (!term) return null;\n return { label: fill(t.reasonVariant, { t: term, f: field }), tinted: false, soft: false };\n\n case 'synonym': {\n if (!term) return null;\n // The first word of the query is the one that most likely produced the\n // synonym edge. Imperfect on a multi-word query, and better than naming\n // the whole query, which reads as if all of it matched.\n const q = query.trim().split(/\\s+/)[0] ?? '';\n return { label: fill(t.reasonSynonym, { t: term, q }), tinted: true, soft: false };\n }\n\n case 'vector':\n return { label: t.reasonVector, tinted: false, soft: true };\n\n case 'semantic': {\n // A model-authored reason is already a full sentence in the shopper's\n // language, so it is shown verbatim rather than templated. Tinted like\n // the confident types: it is an affirmative \"why this matched\", not the\n // apologetic vector note. Empty text renders nothing rather than a blank\n // chip.\n const text = (reason.text ?? '').trim();\n if (!text) return null;\n return { label: text, tinted: true, soft: false };\n }\n\n default:\n // An unfamiliar type renders as nothing rather than as a guess.\n return null;\n }\n}\n\n/** Every renderable chip for one result, in the order the API ranked them. */\nexport function toChips(\n reasons: MatchReason[] | undefined,\n t: Strings = EN_REASON_LABELS,\n query = '',\n): ReasonChip[] {\n if (!Array.isArray(reasons)) return [];\n return reasons\n .map((r) => toChip(r, t, query))\n .filter((c): c is ReasonChip => c !== null);\n}\n"]}
|