@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 CHANGED
@@ -3,40 +3,39 @@ interface SkawrSearchConfig {
3
3
  baseUrl?: string;
4
4
  timeout?: number;
5
5
  /**
6
- * True when this client does not run on the merchant's own storefront an
7
- * onboarding playground, a shared store link, a demo page.
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
- * The API treats the first search from a storefront as the moment that store
10
- * went live. Set this and it won't: every request carries `X-Skawr-Preview`.
11
- * Leave it unset on a real storefront, which is the default.
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
- * Send `enable_personalization`. Defaults to true, which is what this SDK has
16
- * always done.
15
+ * Let a shopper's own activity influence their result order. Defaults to true.
17
16
  *
18
- * It is settable because this library runs on the customer's domain, and
19
- * whether a shopper's behaviour shapes their results is the site owner's call
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
- * Where the anonymous shopper id comes from. Defaults to `'local'`: generate
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
- * `'none'` stores nothing and sends no id. Personalization and the dense
28
- * pagination that keys off `shopper_id` both degrade without it — that is the
29
- * trade, and it belongs to whoever owns the site.
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 own `timeout` because one client legitimately
37
- * makes calls with different ceilings a two-phase search wants a short one
38
- * for the retrieval pass and a long one for the reranked pass, and creating a
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
- * Stable per-shopper id scoping the pagination session, so numbered pages
72
- * stay dense and de-duplicated (skawr-search#542). Defaults to the SDK's
73
- * anonymous id; set it explicitly to use your own (a logged-in user id).
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 model reranker. Omit (or `true`) for a normal search.
97
+ * Run the reranking stage. Omit (or `true`) for a normal search.
97
98
  *
98
- * Search has two stages. Retrieval lexical and vector together — is fast.
99
- * The model rerank that follows is not: measured against production it is
100
- * ~9.4s of a ~10.5s search, so a single blocking call spends almost all of
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` returns retrieval-order results immediately, along with the
104
- * `search_id` to pass to {@link SearchOptions.search_id} on a second call.
105
- * That is the two-phase shape: paint the fast pass, replace it when the
106
- * reranked pass lands. A client that ignores this gets one blocking call and
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` from a `rerank: false` fast pass, echoed back on the refine
112
- * pass so the two calls correlate to one query rather than being counted and
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 (skawr-search#507 / #522 / #528).
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. Sent whenever the account has match chips enabled, which
153
- * is the default in production.
150
+ * Why this result matched. Render them with {@link toChips}.
154
151
  *
155
- * It was arriving all along and only reachable through the index signature
156
- * below — so every consumer that wanted to explain a result had to know the
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 the keyword query across the whole index, which
183
- * is a different and usually much larger number than `total_results`
184
- * (skawr-search#538). Null when the server did not compute it.
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
- * Load-bearing, not laziness.
211
+ * Fields added to the API in future reach you without an SDK upgrade.
218
212
  *
219
- * This type used to be closed, and the server sends 25 fields to its nine —
220
- * so `next_cursor`, `matched_count`, `fallback` and a dozen more were dropped
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
- * Every consumer so far has had to rebuild this from `status`, because a
281
- * number is not a decision. The widget classifies 402 as "unmount silently"
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. "This catalogue has no
292
- * matching products" is a statement about the merchant's data and belongs in
293
- * `results: []`; everything here is a statement about us.
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 — the account's subscription lapsed. Stop asking; say nothing to shoppers. */
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 — rate limit or a spent preview allowance. Backing off may help. */
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. Ours to fix; one retry, then a gentle message. */
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 the caller abandoned this request, so there is nobody to tell.
302
+ * True when you aborted this request yourself.
323
303
  *
324
- * The case this exists for: a debounced search box aborts the request for
325
- * every keystroke but the last, and rendering "search is unavailable" for
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
- * Turning a match reason into a chip a shopper can read
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 }` (computed) or `{ type: "semantic", text }`
336
- * (model-authored) and no display string. That is the right shapea structured
337
- * reason survives translation and restyling, a pre-rendered English phrase does
338
- * not — but it means the wording has to live somewhere. It lives here, in the
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 oneand 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, no framework: this returns text and two style flags, and the caller
346
- * draws them. Callers pass a `Strings` map for the computed labels (localized);
347
- * `EN_REASON_LABELS` is the default for surfaces without their own i18n.
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 copy has to hold:
322
+ * Two rules the output holds to:
350
323
  *
351
- * 1. An unknown `type` renders as nothing rather than as something invented.
352
- * The vocabulary is open on purpose and more types may follow. A chip that
353
- * guessed at an unfamiliar type would be the one kind of reason that is not
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` says plainly that nothing in the query appeared in the product's
357
- * text. It is the chip that explains a surprising result, so it must not be
358
- * dressed up to look as confident as a literal match.
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":"AAqCA,SAASA,CAAAA,CAAeC,EAA2B,CACjD,OAAIA,IAAW,GAAA,CAAY,sBAAA,CACvBA,CAAAA,GAAW,GAAA,EAAOA,CAAAA,GAAW,GAAA,CAAY,cACzCA,CAAAA,GAAW,GAAA,CAAY,eACvBA,CAAAA,EAAU,GAAA,CAAY,eAKnB,SACT,CAEO,IAAMC,CAAAA,CAAN,cAA0B,KAAM,CAC5B,MAAA,CACA,MAAA,CAKA,KAET,WAAA,CAAYC,CAAAA,CAAiBF,EAAgBG,CAAAA,CAAiBC,CAAAA,CAAkB,CAC9E,KAAA,CAAMF,CAAO,CAAA,CACb,KAAK,IAAA,CAAO,aAAA,CACZ,KAAK,MAAA,CAASF,CAAAA,CACd,KAAK,MAAA,CAASG,CAAAA,EAAUD,CAAAA,CACxB,IAAA,CAAK,IAAA,CAAOE,CAAAA,EAAQL,EAAeC,CAAM,EAC3C,CASA,IAAI,SAAA,EAAqB,CACvB,OAAO,IAAA,CAAK,IAAA,GAAS,SACvB,CACF,EChEA,IAAMK,CAAAA,CAAmB,uBAAA,CACnBC,EAAkB,GAAA,CAClBC,CAAAA,CAAc,gBAGpB,SAASC,CAAAA,EAAyB,CAChC,OAAI,OAAO,MAAA,CAAW,KAAe,MAAA,CAAO,UAAA,CAAmB,OAAO,UAAA,EAAW,CAC1E,uCAAuC,OAAA,CAAQ,OAAA,CAAUC,CAAAA,EAAM,CACpE,IAAMC,CAAAA,CAAK,KAAK,MAAA,EAAO,CAAI,GAAM,CAAA,CAEjC,OAAA,CADUD,IAAM,GAAA,CAAMC,CAAAA,CAAKA,CAAAA,CAAI,CAAA,CAAO,CAAA,EAC7B,QAAA,CAAS,EAAE,CACtB,CAAC,CACH,CAGA,SAASC,GAAwC,CAC/C,GAAI,EAAA,OAAO,MAAA,CAAW,GAAA,EAAe,OAAO,aAAiB,GAAA,CAAA,CAC7D,GAAI,CACF,IAAIC,CAAAA,CAAK,aAAa,OAAA,CAAQL,CAAW,CAAA,CACzC,OAAKK,CAAAA,GACHA,CAAAA,CAAKJ,GAAe,CACpB,YAAA,CAAa,QAAQD,CAAAA,CAAaK,CAAE,GAE/BA,CACT,CAAA,KAAQ,CAEN,MACF,CACF,CASA,SAASC,CAAAA,CAA2CC,CAAAA,CAAoB,CACtE,IAAMC,CAAAA,CAA+B,EAAC,CACtC,IAAA,GAAW,CAACC,CAAAA,CAAKC,CAAK,CAAA,GAAK,OAAO,OAAA,CAAQH,CAAG,EACvCG,CAAAA,GAAU,MAAA,GAAWF,EAAIC,CAAG,CAAA,CAAIC,CAAAA,CAAAA,CAEtC,OAAOF,CACT,KAEaG,CAAAA,CAAN,KAAkB,CACN,OAAA,CACA,SAAA,CACA,QACA,OAAA,CACA,eAAA,CACA,QAAA,CAEjB,WAAA,CAAYC,CAAAA,CAA2B,CACrC,GAAI,CAACA,CAAAA,CAAO,UACV,MAAM,IAAI,MAAM,uBAAuB,CAAA,CAGzC,IAAA,CAAK,OAAA,CAAA,CAAWA,CAAAA,CAAO,OAAA,EAAWd,GAAkB,OAAA,CAAQ,MAAA,CAAQ,EAAE,CAAA,CACtE,IAAA,CAAK,SAAA,CAAYc,EAAO,SAAA,CACxB,IAAA,CAAK,OAAA,CAAUA,CAAAA,CAAO,OAAA,EAAWb,CAAAA,CACjC,KAAK,OAAA,CAAUa,CAAAA,CAAO,SAAW,KAAA,CAKjC,IAAA,CAAK,gBAAkBA,CAAAA,CAAO,eAAA,EAAmB,IAAA,CACjD,IAAA,CAAK,QAAA,CAAWA,CAAAA,CAAO,UAAY,QACrC,CAGQ,aAAkC,CACxC,OAAO,KAAK,QAAA,GAAa,MAAA,CAAS,MAAA,CAAYR,CAAAA,EAChD,CAEA,MAAM,MAAA,CAAOS,CAAAA,CAAeC,EAAkD,CAY5E,IAAMC,EAASD,CAAAA,EAAS,YAAA,EAAgB,IAAA,CAAK,WAAA,EAAY,CAMzD,OAAO,KAAK,IAAA,CACV,gBAAA,CACA,CACE,KAAA,CAAAD,CAAAA,CACA,GAAGP,CAAAA,CAAQ,CACT,OAAA,CAASQ,CAAAA,EAAS,OAAA,CAClB,IAAA,CAAMA,GAAS,IAAA,CACf,QAAA,CAAUA,GAAS,QAAA,CACnB,MAAA,CAAQA,GAAS,MAAA,CACjB,OAAA,CAASA,CAAAA,EAAS,OAAA,CAClB,iBAAA,CAAmBA,CAAAA,EAAS,kBAC5B,mBAAA,CAAqBA,CAAAA,EAAS,oBAC9B,iBAAA,CAAmBA,CAAAA,EAAS,kBAC5B,aAAA,CAAeA,CAAAA,EAAS,aAAA,CACxB,QAAA,CAAUA,CAAAA,EAAS,QAAA,CAKnB,OAAQA,CAAAA,EAAS,MAAA,GAAW,MAAQ,KAAA,CAAQ,MAAA,CAC5C,UAAWA,CAAAA,EAAS,SAAA,EAAa,MAAA,CACjC,UAAA,CAAYA,CAAAA,EAAS,UAAA,EAAcC,EACnC,YAAA,CAAcA,CAAAA,CACd,sBAAA,CAAwB,IAAA,CAAK,eAAA,CAAkB,IAAA,CAAO,MACxD,CAAC,CACH,CAAA,CACAD,CACF,CACF,CAEA,MAAM,OAAA,CAAQD,CAAAA,CAAeC,EAAwD,CACnF,IAAME,EAAiC,CAAE,CAAA,CAAGH,CAAM,CAAA,CAClD,OAAIC,CAAAA,EAAS,QAAU,MAAA,GAAWE,CAAAA,CAAO,MAAQ,MAAA,CAAOF,CAAAA,CAAQ,KAAK,CAAA,CAAA,CACjEA,CAAAA,EAAS,gBAAA,GAAqB,MAAA,GAAWE,CAAAA,CAAO,gBAAA,CAAmB,OAAOF,CAAAA,CAAQ,gBAAgB,GAE/F,IAAA,CAAK,GAAA,CAAyB,6BAA8BE,CAAM,CAC3E,CAEA,MAAM,YAAA,CAAaH,CAAAA,CAAeC,EAA8D,CAC9F,IAAME,EAAiC,CAAE,CAAA,CAAGH,CAAM,CAAA,CAClD,OAAIC,CAAAA,EAAS,KAAA,GAAU,MAAA,GAAWE,CAAAA,CAAO,MAAQ,MAAA,CAAOF,CAAAA,CAAQ,KAAK,CAAA,CAAA,CAE9D,IAAA,CAAK,IAA0B,sBAAA,CAAwBE,CAAM,CACtE,CAEA,MAAc,GAAA,CACZC,EACAD,CAAAA,CACAE,CAAAA,CACY,CACZ,IAAIC,CAAAA,CAAM,GAAG,IAAA,CAAK,OAAO,CAAA,EAAGF,CAAI,CAAA,CAAA,CAChC,GAAID,EAAQ,CACV,IAAMI,EAAK,IAAI,eAAA,CAAgBJ,CAAM,CAAA,CAAE,QAAA,EAAS,CAC5CI,CAAAA,GAAID,CAAAA,EAAO,CAAA,CAAA,EAAIC,CAAE,CAAA,CAAA,EACvB,CAEA,OAAO,IAAA,CAAK,OAAA,CAAWD,CAAAA,CAAK,CAAE,MAAA,CAAQ,KAAM,CAAA,CAAGD,CAAI,CACrD,CAEA,MAAc,IAAA,CAAQD,CAAAA,CAAcI,EAAeH,CAAAA,CAAmC,CACpF,OAAO,IAAA,CAAK,OAAA,CACV,CAAA,EAAG,IAAA,CAAK,OAAO,CAAA,EAAGD,CAAI,CAAA,CAAA,CACtB,CAAE,OAAQ,MAAA,CAAQ,IAAA,CAAM,KAAK,SAAA,CAAUI,CAAI,CAAE,CAAA,CAC7CH,CACF,CACF,CAcQ,SAAA,CAAUA,CAAAA,CAAoC,CACpD,IAAMI,CAAAA,CAAU,YAAY,OAAA,CAAQJ,CAAAA,EAAM,OAAA,EAAW,IAAA,CAAK,OAAO,CAAA,CACjE,OAAOA,CAAAA,EAAM,MAAA,CAAS,YAAY,GAAA,CAAI,CAACA,EAAK,MAAA,CAAQI,CAAO,CAAC,CAAA,CAAIA,CAClE,CAEA,MAAc,OAAA,CAAWH,CAAAA,CAAaI,EAAmBL,CAAAA,CAAmC,CAC1F,IAAMM,CAAAA,CAAkC,CACtC,WAAA,CAAa,IAAA,CAAK,SAAA,CAClB,MAAA,CAAU,kBACZ,CAAA,CAOI,IAAA,CAAK,UACPA,CAAAA,CAAQ,iBAAiB,EAAI,GAAA,CAAA,CAG3BD,CAAAA,CAAK,IAAA,GACPC,CAAAA,CAAQ,cAAc,CAAA,CAAI,oBAG5B,IAAIC,CAAAA,CACJ,GAAI,CACFA,CAAAA,CAAW,MAAM,KAAA,CAAMN,CAAAA,CAAK,CAAE,GAAGI,CAAAA,CAAM,OAAA,CAAAC,EAAS,MAAA,CAAQ,IAAA,CAAK,UAAUN,CAAI,CAAE,CAAC,EAChF,CAAA,MAASQ,CAAAA,CAAO,CAId,MAAIA,CAAAA,YAAiB,cAAgBA,CAAAA,CAAM,IAAA,GAAS,eAC5C,IAAIhC,CAAAA,CAAY,oBAAqB,CAAA,CAAG,mBAAA,CAAqB,SAAS,CAAA,CAE1EgC,CAAAA,YAAiB,YAAA,EAAgBA,EAAM,IAAA,GAAS,YAAA,CAC5C,IAAIhC,CAAAA,CAAY,iBAAA,CAAmB,EAAG,iBAAA,CAAmB,SAAS,CAAA,CAKpE,IAAIA,CAAAA,CACR,oCAAA,CACA,EACAgC,CAAAA,YAAiB,KAAA,CAAQA,EAAM,OAAA,CAAU,MAAA,CAAOA,CAAK,CAAA,CACrD,SACF,CACF,CAEA,GAAI,CAACD,EAAS,EAAA,CAAI,CAChB,IAAME,CAAAA,CAAO,MAAMF,EAAS,IAAA,EAAK,CAC7B7B,CAAAA,CACJ,GAAI,CACF,IAAMgC,EAAS,IAAA,CAAK,KAAA,CAAMD,CAAI,CAAA,CAC9B/B,CAAAA,CAAS,OAAOgC,CAAAA,CAAO,MAAA,EAAW,QAAA,CAAWA,CAAAA,CAAO,MAAA,CAASD,EAC/D,MAAQ,CACN/B,CAAAA,CAAS+B,GAAQF,CAAAA,CAAS,WAC5B,CACA,MAAM,IAAI/B,CAAAA,CAAYE,CAAAA,CAAQ6B,CAAAA,CAAS,MAAA,CAAQ7B,CAAM,CACvD,CAEA,OAAQ,MAAM6B,CAAAA,CAAS,MACzB,CACF,ECpMO,IAAMI,CAAAA,CAA4B,CACvC,cAAe,cAAA,CACf,aAAA,CAAe,6BAAA,CACf,aAAA,CAAe,uCAAA,CACf,YAAA,CAAc,qBACd,UAAA,CAAY,OAAA,CACZ,gBAAA,CAAkB,aACpB,EAOA,SAASC,EAAWC,CAAAA,CAA2BC,CAAAA,CAAoB,CACjE,OAAID,CAAAA,GAAU,QAAgBC,CAAAA,CAAE,UAAA,CAC5BD,CAAAA,GAAU,aAAA,CAAsBC,CAAAA,CAAE,gBAAA,CAC/BD,GAAS,EAClB,CAEA,SAASE,CAAAA,CAAKC,CAAAA,CAAkBC,EAAwC,CACtE,OAAOD,CAAAA,CAAS,OAAA,CAAQ,WAAA,CAAa,CAACE,EAAGC,CAAAA,GAAcF,CAAAA,CAAOE,CAAC,CAAA,EAAK,EAAE,CACxE,CASO,SAASC,CAAAA,CACdC,CAAAA,CACAP,CAAAA,CAAaH,CAAAA,CACbhB,EAAQ,EAAA,CACW,CACnB,IAAM2B,CAAAA,CAAAA,CAAQD,CAAAA,CAAO,MAAQ,EAAA,EAAI,IAAA,EAAK,CAChCR,CAAAA,CAAQD,CAAAA,CAAWS,CAAAA,CAAO,MAAOP,CAAC,CAAA,CAExC,OAAQO,CAAAA,CAAO,IAAA,EACb,KAAK,SAAA,CACH,OAAKC,CAAAA,CACE,CAAE,KAAA,CAAOP,EAAKD,CAAAA,CAAE,aAAA,CAAe,CAAE,CAAA,CAAGQ,CAAAA,CAAM,EAAGT,CAAM,CAAC,CAAA,CAAG,MAAA,CAAQ,KAAA,CAAO,IAAA,CAAM,KAAM,CAAA,CADvE,IAAA,CAGpB,KAAK,SAAA,CACH,OAAKS,EACE,CAAE,KAAA,CAAOP,CAAAA,CAAKD,CAAAA,CAAE,aAAA,CAAe,CAAE,EAAGQ,CAAAA,CAAM,CAAA,CAAGT,CAAM,CAAC,CAAA,CAAG,MAAA,CAAQ,MAAO,IAAA,CAAM,KAAM,CAAA,CADvE,IAAA,CAGpB,KAAK,SAAA,CAAW,CACd,GAAI,CAACS,EAAM,OAAO,IAAA,CAIlB,IAAMC,CAAAA,CAAI5B,CAAAA,CAAM,IAAA,EAAK,CAAE,KAAA,CAAM,KAAK,EAAE,CAAC,CAAA,EAAK,GAC1C,OAAO,CAAE,MAAOoB,CAAAA,CAAKD,CAAAA,CAAE,aAAA,CAAe,CAAE,CAAA,CAAGQ,CAAAA,CAAM,EAAAC,CAAE,CAAC,EAAG,MAAA,CAAQ,IAAA,CAAM,KAAM,KAAM,CACnF,CAEA,KAAK,QAAA,CACH,OAAO,CAAE,KAAA,CAAOT,CAAAA,CAAE,aAAc,MAAA,CAAQ,KAAA,CAAO,KAAM,IAAK,CAAA,CAE5D,KAAK,UAAA,CAAY,CAMf,IAAML,GAAQY,CAAAA,CAAO,IAAA,EAAQ,IAAI,IAAA,EAAK,CACtC,OAAKZ,CAAAA,CACE,CAAE,KAAA,CAAOA,CAAAA,CAAM,MAAA,CAAQ,IAAA,CAAM,KAAM,KAAM,CAAA,CAD9B,IAEpB,CAEA,QAEE,OAAO,IACX,CACF,CAGO,SAASe,CAAAA,CACdC,CAAAA,CACAX,EAAaH,CAAAA,CACbhB,CAAAA,CAAQ,GACM,CACd,OAAK,MAAM,OAAA,CAAQ8B,CAAO,CAAA,CACnBA,CAAAA,CACJ,GAAA,CAAK,CAAA,EAAML,EAAO,CAAA,CAAGN,CAAAA,CAAGnB,CAAK,CAAC,CAAA,CAC9B,MAAA,CAAQX,GAAuBA,CAAAA,GAAM,IAAI,CAAA,CAHR,EAItC","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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skawr/search",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Official SKAWR browser search SDK — lightweight, search-only client",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",