@skawr/search 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,43 @@ results.results.forEach((item) => {
39
39
  });
40
40
  ```
41
41
 
42
+ ### Why a result matched
43
+
44
+ Every result can carry `match_reasons` — structured reasons, not rendered
45
+ phrases, so they survive translation and restyling. `toChips` turns them into
46
+ readable labels plus two style flags, and you draw them however your UI draws
47
+ small tags:
48
+
49
+ ```typescript
50
+ import { toChips } from '@skawr/search';
51
+
52
+ for (const item of results.results) {
53
+ // Pass the query too: the synonym chip names the word the shopper typed.
54
+ for (const chip of toChips(item.match_reasons, undefined, 'phone')) {
55
+ // chip.label — the text
56
+ // chip.tinted — accent treatment (synonym, model-authored reason)
57
+ // chip.soft — the "matched by meaning" chip, which admits no word matched
58
+ }
59
+ }
60
+ ```
61
+
62
+ Pass your own label map as the second argument to localize the computed chips:
63
+
64
+ ```typescript
65
+ toChips(item.match_reasons, {
66
+ reasonLiteral: '{t} · {f}',
67
+ reasonVariant: '{t} · {f} · صيغة الكلمة',
68
+ reasonSynonym: '{t} — مرادف لـ «{q}»',
69
+ reasonVector: 'طابق في المعنى',
70
+ fieldTitle: 'العنوان',
71
+ fieldDescription: 'الوصف',
72
+ }, query);
73
+ ```
74
+
75
+ A reason type the SDK does not recognise renders as nothing rather than as a
76
+ guess — the vocabulary is open and more types may follow, and a guessed chip
77
+ would be the one that is not evidence-backed.
78
+
42
79
  ### Suggestions
43
80
 
44
81
  ```typescript
@@ -65,6 +102,7 @@ console.log(suggestions); // ['laptop', 'laptop case', 'laptop stand']
65
102
  | `publicKey` | `string` | *required* | Your SKAWR public/search key |
66
103
  | `baseUrl` | `string` | `https://api.skawr.com` | API base URL |
67
104
  | `timeout` | `number` | `10000` | Request timeout in ms |
105
+ | `preview` | `boolean` | `false` | Set on a surface that is **not** the merchant's storefront — an onboarding playground, a shared store link, a demo. The API treats the first search from a storefront as the moment that store went live; a preview client does not trigger that. |
68
106
 
69
107
  ## Error Handling
70
108
 
package/dist/index.d.ts CHANGED
@@ -2,6 +2,51 @@ interface SkawrSearchConfig {
2
2
  publicKey: string;
3
3
  baseUrl?: string;
4
4
  timeout?: number;
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.
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.
12
+ */
13
+ preview?: boolean;
14
+ /**
15
+ * Send `enable_personalization`. Defaults to true, which is what this SDK has
16
+ * always done.
17
+ *
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.
21
+ */
22
+ personalization?: boolean;
23
+ /**
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.
26
+ *
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.
30
+ */
31
+ identity?: 'local' | 'none';
32
+ }
33
+ /**
34
+ * Per-call cancellation and deadline, accepted by every request method.
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.
40
+ */
41
+ interface RequestControl {
42
+ /**
43
+ * Abort this request. A debounced search box passes a fresh signal per
44
+ * keystroke and aborts the previous one; the resulting `SearchError` has
45
+ * `kind: 'aborted'`, which a caller is meant to ignore rather than render.
46
+ */
47
+ signal?: AbortSignal;
48
+ /** Milliseconds for this call only. Falls back to the client's `timeout`. */
49
+ timeout?: number;
5
50
  }
6
51
  interface SearchFilters {
7
52
  min_price?: number;
@@ -12,15 +57,82 @@ interface SearchFilters {
12
57
  boost_fields?: Record<string, number>;
13
58
  }
14
59
  type SortBy = 'relevance' | 'price_asc' | 'price_desc' | 'date_desc';
15
- interface SearchOptions {
60
+ interface SearchOptions extends RequestControl {
16
61
  filters?: SearchFilters;
17
62
  page?: number;
18
63
  per_page?: number;
64
+ /**
65
+ * Opaque cursor from a previous response's `next_cursor`, for dense
66
+ * pagination that cannot repeat or skip products the way numbered pages can.
67
+ * Mutually exclusive with `page` in practice — the server follows the cursor.
68
+ */
69
+ cursor?: string;
70
+ /**
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).
74
+ */
75
+ shopper_id?: string;
19
76
  sort_by?: SortBy;
77
+ /** Ask for the response's `analytics` block. */
78
+ include_analytics?: boolean;
79
+ /**
80
+ * Per-request feature overrides, e.g. `{ reranker: 'off', match_chips: false }`.
81
+ * Honoured only where the server allows request-level overrides; unknown keys
82
+ * are ignored rather than rejected.
83
+ */
84
+ features?: Record<string, unknown>;
20
85
  highlight_matches?: boolean;
86
+ /**
87
+ * Ask for query suggestions alongside the results, returned as
88
+ * `SearchResponse.suggestions`. Off at the API unless requested, because
89
+ * computing them costs a second pass.
90
+ */
91
+ include_suggestions?: boolean;
21
92
  result_format?: 'standard' | 'minimal' | 'detailed';
22
93
  /** Anonymous shopper ID for personalization. Generated and persisted automatically by the SDK. */
23
94
  anonymous_id?: string;
95
+ /**
96
+ * Run the model reranker. Omit (or `true`) for a normal search.
97
+ *
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.
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.
108
+ */
109
+ rerank?: boolean;
110
+ /**
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.
114
+ */
115
+ search_id?: string;
116
+ }
117
+ /**
118
+ * Why a result matched, as the API sends it (skawr-search#507 / #522 / #528).
119
+ *
120
+ * Structured, not a rendered phrase: `{type, field, term}` for a computed
121
+ * reason and `{type: "semantic", text}` for the model-authored one. A
122
+ * pre-rendered English string would not survive translation or restyling, so
123
+ * the wording belongs to whoever draws the chip.
124
+ *
125
+ * The vocabulary is open — more types may follow — so a renderer that meets an
126
+ * unfamiliar `type` should draw nothing rather than invent a label. A guessed
127
+ * chip is the one kind that is not evidence-backed, which defeats the point.
128
+ */
129
+ interface MatchReason {
130
+ type: string;
131
+ field?: string;
132
+ term?: string;
133
+ /** Present on `type: "semantic"` — the model's own sentence, shown verbatim. */
134
+ text?: string;
135
+ [key: string]: unknown;
24
136
  }
25
137
  interface SearchResult {
26
138
  id: string;
@@ -36,6 +148,22 @@ interface SearchResult {
36
148
  score?: number;
37
149
  highlighted_title?: string;
38
150
  highlighted_description?: string;
151
+ /**
152
+ * Why this matched. Sent whenever the account has match chips enabled, which
153
+ * is the default in production.
154
+ *
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.
160
+ */
161
+ match_reasons?: MatchReason[];
162
+ /** True when the query matched this product's text literally. */
163
+ exact_match?: boolean;
164
+ brand?: string;
165
+ /** Ranking contribution from the shopper's own history, when personalised. */
166
+ personalization_boost?: number;
39
167
  [key: string]: unknown;
40
168
  }
41
169
  interface FacetCount {
@@ -48,14 +176,56 @@ interface Facet {
48
176
  }
49
177
  interface SearchResponse {
50
178
  results: SearchResult[];
179
+ /** How many results are pageable. NOT how many matched — see `matched_count`. */
51
180
  total_results: number;
181
+ /**
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.
185
+ */
186
+ matched_count?: number | null;
187
+ page_result_count?: number;
52
188
  page: number;
53
189
  per_page: number;
54
190
  total_pages: number;
191
+ /** Cursor for the next page; pass back as `SearchOptions.cursor`. */
192
+ next_cursor?: string | null;
193
+ /** Whether a further page exists, on the cursor path. */
194
+ has_more?: boolean | null;
55
195
  took_ms: number;
196
+ /** Time inside the search engine, excluding transport and serialisation. */
197
+ search_took_ms?: number;
56
198
  facets?: Facet[];
199
+ /** Car-specific aggregations; null on non-car queries. */
200
+ car_facets?: Record<string, unknown> | null;
201
+ dominant_bucket?: string | null;
57
202
  suggestions?: string[];
203
+ performance_metrics?: Record<string, unknown>;
204
+ analytics?: Record<string, unknown> | null;
205
+ filters_applied?: Record<string, unknown> | null;
206
+ /** What the server understood the query to be about: brand, model, category. */
207
+ query_understanding?: Record<string, unknown> | null;
208
+ api_version?: string;
58
209
  search_id: string;
210
+ /** True when the server served a degraded answer rather than a full search. */
211
+ fallback?: boolean;
212
+ cached?: boolean;
213
+ /** Whether personalization actually applied, not merely whether it was asked for. */
214
+ personalized?: boolean;
215
+ show_demand_filter?: boolean;
216
+ /**
217
+ * Load-bearing, not laziness.
218
+ *
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.
227
+ */
228
+ [key: string]: unknown;
59
229
  }
60
230
  interface SuggestOptions {
61
231
  limit?: number;
@@ -77,19 +247,143 @@ declare class SkawrSearch {
77
247
  private readonly baseUrl;
78
248
  private readonly publicKey;
79
249
  private readonly timeout;
250
+ private readonly preview;
251
+ private readonly personalization;
252
+ private readonly identity;
80
253
  constructor(config: SkawrSearchConfig);
254
+ /** The stored anonymous id, or undefined when identity is off. */
255
+ private anonymousId;
81
256
  search(query: string, options?: SearchOptions): Promise<SearchResponse>;
82
257
  suggest(query: string, options?: SuggestOptions): Promise<SuggestionsResponse>;
83
258
  autocomplete(query: string, options?: AutocompleteOptions): Promise<AutocompleteResponse>;
84
259
  private get;
85
260
  private post;
261
+ /**
262
+ * The signal for one request: the caller's, this call's timeout, or both.
263
+ *
264
+ * Both matters. A debounced box aborts superseded keystrokes through its own
265
+ * signal, and still wants the timeout for the request it keeps — before this,
266
+ * `AbortSignal.timeout()` was the only signal a request could have, so a
267
+ * caller could not cancel anything.
268
+ *
269
+ * `AbortSignal.any` is the standard combinator and is present everywhere
270
+ * `AbortSignal.timeout` is (both landed together), so a caller that can time
271
+ * out can also combine.
272
+ */
273
+ private signalFor;
86
274
  private request;
87
275
  }
88
276
 
277
+ /**
278
+ * Why a search failed, in terms a UI can act on.
279
+ *
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.
290
+ *
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.
294
+ */
295
+ type ErrorKind =
296
+ /** 402 — the account's subscription lapsed. Stop asking; say nothing to shoppers. */
297
+ 'subscription-expired'
298
+ /** 401/403 — the key is wrong, revoked, or not allowed from this origin. */
299
+ | 'invalid-key'
300
+ /** 429 — rate limit or a spent preview allowance. Backing off may help. */
301
+ | 'rate-limited'
302
+ /** The request exceeded its timeout. Retrying is reasonable. */
303
+ | 'timeout'
304
+ /** The caller aborted it — a superseded keystroke, a closed page. Not a fault. */
305
+ | 'aborted'
306
+ /** 5xx. Ours to fix; one retry, then a gentle message. */
307
+ | 'server-error'
308
+ /** Never reached the server at all: DNS, offline, CORS, TLS. */
309
+ | 'network'
310
+ /** A 4xx we have no specific handling for. */
311
+ | 'unknown';
89
312
  declare class SearchError extends Error {
90
313
  readonly status: number;
91
314
  readonly detail: string;
92
- constructor(message: string, status: number, detail?: string);
315
+ /**
316
+ * What kind of failure this is. Prefer branching on this over `status`:
317
+ * `aborted`, `timeout` and `network` never had a status to begin with.
318
+ */
319
+ readonly kind: ErrorKind;
320
+ constructor(message: string, status: number, detail?: string, kind?: ErrorKind);
321
+ /**
322
+ * True when the caller abandoned this request, so there is nobody to tell.
323
+ *
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.
327
+ */
328
+ get isAborted(): boolean;
329
+ }
330
+
331
+ /**
332
+ * Turning a match reason into a chip a shopper can read
333
+ * (skawr-search#507 / #522 / #528).
334
+ *
335
+ * The API sends `{ type, field, term }` (computed) or `{ type: "semantic", text }`
336
+ * (model-authored) and no display string. That is the right shape — a 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).
344
+ *
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.
348
+ *
349
+ * Two rules the copy has to hold:
350
+ *
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.
355
+ *
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.
359
+ */
360
+
361
+ interface ReasonChip {
362
+ /** Text to display, already localized. */
363
+ label: string;
364
+ /** True for the reason types that carry the accent treatment. */
365
+ tinted: boolean;
366
+ /** True for `vector`, which is styled apart from the confident types. */
367
+ soft: boolean;
93
368
  }
369
+ /**
370
+ * Label templates, keyed by name. Typed loosely on purpose: a caller's i18n
371
+ * module is a `Record<string, string>` and pretending otherwise would only move
372
+ * the looseness out of sight. Missing keys render as an empty span of text
373
+ * rather than throwing — `EN_REASON_LABELS` below names every key in use.
374
+ */
375
+ type Strings = Record<string, string>;
376
+ /** Default English labels, for a surface with no localized `Strings`. */
377
+ declare const EN_REASON_LABELS: Strings;
378
+ /**
379
+ * One chip, or null when the reason cannot be rendered honestly.
380
+ *
381
+ * `query` is needed only by the synonym chip, which names the word the shopper
382
+ * actually typed so the cross-language jump is legible: seeing `جوال` next to
383
+ * a search for `phone` is confusing without it.
384
+ */
385
+ declare function toChip(reason: MatchReason, t?: Strings, query?: string): ReasonChip | null;
386
+ /** Every renderable chip for one result, in the order the API ranked them. */
387
+ declare function toChips(reasons: MatchReason[] | undefined, t?: Strings, query?: string): ReasonChip[];
94
388
 
95
- export { type AutocompleteOptions, type AutocompleteResponse, type Facet, type FacetCount, SearchError, type SearchFilters, type SearchOptions, type SearchResponse, type SearchResult, SkawrSearch, type SkawrSearchConfig, type SortBy, type SuggestOptions, type SuggestionsResponse };
389
+ export { type AutocompleteOptions, type AutocompleteResponse, EN_REASON_LABELS, type ErrorKind, type Facet, type FacetCount, type MatchReason, type ReasonChip, type RequestControl, SearchError, type SearchFilters, type SearchOptions, type SearchResponse, type SearchResult, SkawrSearch, type SkawrSearchConfig, type SortBy, type Strings, type SuggestOptions, type SuggestionsResponse, toChip, toChips };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- var o=class extends Error{status;detail;constructor(t,e,r){super(t),this.name="SearchError",this.status=e,this.detail=r??t;}};var g="https://api.skawr.com",l=1e4,p="skawr_anon_id";function d(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,n=>{let t=Math.random()*16|0;return (n==="x"?t:t&3|8).toString(16)})}function m(){if(!(typeof window>"u"||typeof localStorage>"u"))try{let n=localStorage.getItem(p);return n||(n=d(),localStorage.setItem(p,n)),n}catch{return}}var c=class{baseUrl;publicKey;timeout;constructor(t){if(!t.publicKey)throw new Error("publicKey is required");this.baseUrl=(t.baseUrl??g).replace(/\/+$/,""),this.publicKey=t.publicKey,this.timeout=t.timeout??l;}async search(t,e){let r=e?.anonymous_id??m();return this.post("/api/v1/search",{query:t,filters:e?.filters,page:e?.page,per_page:e?.per_page,sort_by:e?.sort_by,highlight_matches:e?.highlight_matches??true,result_format:e?.result_format,enable_personalization:true,anonymous_id:r})}async suggest(t,e){let r={q:t};return e?.limit!==void 0&&(r.limit=String(e.limit)),e?.include_trending!==void 0&&(r.include_trending=String(e.include_trending)),this.get("/api/v1/search/suggestions",r)}async autocomplete(t,e){let r={q:t};return e?.limit!==void 0&&(r.limit=String(e.limit)),this.get("/api/v1/autocomplete",r)}async get(t,e){let r=`${this.baseUrl}${t}`;if(e){let s=new URLSearchParams(e).toString();s&&(r+=`?${s}`);}return this.request(r,{method:"GET"})}async post(t,e){return this.request(`${this.baseUrl}${t}`,{method:"POST",body:JSON.stringify(e)})}async request(t,e){let r={"X-API-Key":this.publicKey,Accept:"application/json"};e.body&&(r["Content-Type"]="application/json");let s;try{s=await fetch(t,{...e,headers:r,signal:AbortSignal.timeout(this.timeout)});}catch(i){throw i instanceof DOMException&&i.name==="TimeoutError"?new o("Request timed out",0,"Request timed out"):i}if(!s.ok){let i=await s.text(),a;try{let u=JSON.parse(i);a=typeof u.detail=="string"?u.detail:i;}catch{a=i||s.statusText;}throw new o(a,s.status,a)}return await s.json()}};
2
- export{o as SearchError,c as SkawrSearch};//# sourceMappingURL=index.js.map
1
+ function h(n){return n===402?"subscription-expired":n===401||n===403?"invalid-key":n===429?"rate-limited":n>=500?"server-error":"unknown"}var a=class extends Error{status;detail;kind;constructor(e,t,r,s){super(e),this.name="SearchError",this.status=t,this.detail=r??e,this.kind=s??h(t);}get isAborted(){return this.kind==="aborted"}};var m="https://api.skawr.com",y=1e4,f="skawr_anon_id";function x(){return typeof crypto<"u"&&crypto.randomUUID?crypto.randomUUID():"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,n=>{let e=Math.random()*16|0;return (n==="x"?e:e&3|8).toString(16)})}function S(){if(!(typeof window>"u"||typeof localStorage>"u"))try{let n=localStorage.getItem(f);return n||(n=x(),localStorage.setItem(f,n)),n}catch{return}}function R(n){let e={};for(let[t,r]of Object.entries(n))r!==void 0&&(e[t]=r);return e}var l=class{baseUrl;publicKey;timeout;preview;personalization;identity;constructor(e){if(!e.publicKey)throw new Error("publicKey is required");this.baseUrl=(e.baseUrl??m).replace(/\/+$/,""),this.publicKey=e.publicKey,this.timeout=e.timeout??y,this.preview=e.preview??false,this.personalization=e.personalization??true,this.identity=e.identity??"local";}anonymousId(){return this.identity==="none"?void 0:S()}async search(e,t){let r=t?.anonymous_id??this.anonymousId();return this.post("/api/v1/search",{query:e,...R({filters:t?.filters,page:t?.page,per_page:t?.per_page,cursor:t?.cursor,sort_by:t?.sort_by,highlight_matches:t?.highlight_matches,include_suggestions:t?.include_suggestions,include_analytics:t?.include_analytics,result_format:t?.result_format,features:t?.features,rerank:t?.rerank===false?false:void 0,search_id:t?.search_id||void 0,shopper_id:t?.shopper_id??r,anonymous_id:r,enable_personalization:this.personalization?true:void 0})},t)}async suggest(e,t){let r={q:e};return t?.limit!==void 0&&(r.limit=String(t.limit)),t?.include_trending!==void 0&&(r.include_trending=String(t.include_trending)),this.get("/api/v1/search/suggestions",r)}async autocomplete(e,t){let r={q:e};return t?.limit!==void 0&&(r.limit=String(t.limit)),this.get("/api/v1/autocomplete",r)}async get(e,t,r){let s=`${this.baseUrl}${e}`;if(t){let i=new URLSearchParams(t).toString();i&&(s+=`?${i}`);}return this.request(s,{method:"GET"},r)}async post(e,t,r){return this.request(`${this.baseUrl}${e}`,{method:"POST",body:JSON.stringify(t)},r)}signalFor(e){let t=AbortSignal.timeout(e?.timeout??this.timeout);return e?.signal?AbortSignal.any([e.signal,t]):t}async request(e,t,r){let s={"X-API-Key":this.publicKey,Accept:"application/json"};this.preview&&(s["X-Skawr-Preview"]="1"),t.body&&(s["Content-Type"]="application/json");let i;try{i=await fetch(e,{...t,headers:s,signal:this.signalFor(r)});}catch(o){throw o instanceof DOMException&&o.name==="TimeoutError"?new a("Request timed out",0,"Request timed out","timeout"):o instanceof DOMException&&o.name==="AbortError"?new a("Request aborted",0,"Request aborted","aborted"):new a("Could not reach the search service",0,o instanceof Error?o.message:String(o),"network")}if(!i.ok){let o=await i.text(),u;try{let p=JSON.parse(o);u=typeof p.detail=="string"?p.detail:o;}catch{u=o||i.statusText;}throw new a(u,i.status,u)}return await i.json()}};var d={reasonLiteral:"{t} \xB7 {f}",reasonVariant:"{t} \xB7 {f} \xB7 word form",reasonSynonym:"{t} \u2014 synonym of \u201C{q}\u201D",reasonVector:"matched by meaning",fieldTitle:"title",fieldDescription:"description"};function b(n,e){return n==="title"?e.fieldTitle:n==="description"?e.fieldDescription:n??""}function c(n,e){return n.replace(/\{(\w)\}/g,(t,r)=>e[r]??"")}function g(n,e=d,t=""){let r=(n.term??"").trim(),s=b(n.field,e);switch(n.type){case "literal":return r?{label:c(e.reasonLiteral,{t:r,f:s}),tinted:false,soft:false}:null;case "variant":return r?{label:c(e.reasonVariant,{t:r,f:s}),tinted:false,soft:false}:null;case "synonym":{if(!r)return null;let i=t.trim().split(/\s+/)[0]??"";return {label:c(e.reasonSynonym,{t:r,q:i}),tinted:true,soft:false}}case "vector":return {label:e.reasonVector,tinted:false,soft:true};case "semantic":{let i=(n.text??"").trim();return i?{label:i,tinted:true,soft:false}:null}default:return null}}function w(n,e=d,t=""){return Array.isArray(n)?n.map(r=>g(r,e,t)).filter(r=>r!==null):[]}
2
+ export{d as EN_REASON_LABELS,a as SearchError,l as SkawrSearch,g as toChip,w as toChips};//# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/client.ts"],"names":["SearchError","message","status","detail","DEFAULT_BASE_URL","DEFAULT_TIMEOUT","ANON_ID_KEY","generateAnonId","c","r","getOrCreateAnonId","id","SkawrSearch","config","query","options","anonId","params","path","url","qs","body","init","headers","response","error","text","parsed"],"mappings":"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","file":"index.js","sourcesContent":["export class SearchError extends Error {\n readonly status: number;\n readonly detail: string;\n\n constructor(message: string, status: number, detail?: string) {\n super(message);\n this.name = 'SearchError';\n this.status = status;\n this.detail = detail ?? message;\n }\n}\n","import { SearchError } from './errors.js';\nimport type {\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\nexport class SkawrSearch {\n private readonly baseUrl: string;\n private readonly publicKey: string;\n private readonly timeout: number;\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 }\n\n async search(query: string, options?: SearchOptions): Promise<SearchResponse> {\n const anonId = options?.anonymous_id ?? getOrCreateAnonId();\n return this.post<SearchResponse>('/api/v1/search', {\n query,\n filters: options?.filters,\n page: options?.page,\n per_page: options?.per_page,\n sort_by: options?.sort_by,\n highlight_matches: options?.highlight_matches ?? true,\n result_format: options?.result_format,\n enable_personalization: true,\n anonymous_id: anonId,\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>(path: string, params?: Record<string, string>): 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' });\n }\n\n private async post<T>(path: string, body: unknown): Promise<T> {\n return this.request<T>(`${this.baseUrl}${path}`, {\n method: 'POST',\n body: JSON.stringify(body),\n });\n }\n\n private async request<T>(url: string, init: RequestInit): Promise<T> {\n const headers: Record<string, string> = {\n 'X-API-Key': this.publicKey,\n 'Accept': 'application/json',\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, {\n ...init,\n headers,\n signal: AbortSignal.timeout(this.timeout),\n });\n } catch (error) {\n if (error instanceof DOMException && error.name === 'TimeoutError') {\n throw new SearchError('Request timed out', 0, 'Request timed out');\n }\n throw error;\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"]}
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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skawr/search",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Official SKAWR browser search SDK — lightweight, search-only client",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",