@skawr/search 0.3.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,14 +3,49 @@ 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
+ /**
15
+ * Let a shopper's own activity influence their result order. Defaults to true.
16
+ *
17
+ * Set false to turn personalization off for every request from this client.
18
+ * Results are then the same for everyone.
19
+ */
20
+ personalization?: boolean;
21
+ /**
22
+ * Whether to keep an anonymous shopper id in `localStorage`.
23
+ *
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
+ */
31
+ identity?: 'local' | 'none';
32
+ }
33
+ /**
34
+ * Per-call cancellation and deadline, accepted by every request method.
35
+ *
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.
39
+ */
40
+ interface RequestControl {
41
+ /**
42
+ * Abort this request. A debounced search box passes a fresh signal per
43
+ * keystroke and aborts the previous one; the resulting `SearchError` has
44
+ * `kind: 'aborted'`, which a caller is meant to ignore rather than render.
45
+ */
46
+ signal?: AbortSignal;
47
+ /** Milliseconds for this call only. Falls back to the client's `timeout`. */
48
+ timeout?: number;
14
49
  }
15
50
  interface SearchFilters {
16
51
  min_price?: number;
@@ -21,11 +56,33 @@ interface SearchFilters {
21
56
  boost_fields?: Record<string, number>;
22
57
  }
23
58
  type SortBy = 'relevance' | 'price_asc' | 'price_desc' | 'date_desc';
24
- interface SearchOptions {
59
+ interface SearchOptions extends RequestControl {
25
60
  filters?: SearchFilters;
26
61
  page?: number;
27
62
  per_page?: number;
63
+ /**
64
+ * Opaque cursor from a previous response's `next_cursor`, for dense
65
+ * pagination that cannot repeat or skip products the way numbered pages can.
66
+ * Mutually exclusive with `page` in practice — the server follows the cursor.
67
+ */
68
+ cursor?: string;
69
+ /**
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.
75
+ */
76
+ shopper_id?: string;
28
77
  sort_by?: SortBy;
78
+ /** Ask for the response's `analytics` block. */
79
+ include_analytics?: boolean;
80
+ /**
81
+ * Per-request feature overrides, e.g. `{ reranker: 'off', match_chips: false }`.
82
+ * Honoured only where the server allows request-level overrides; unknown keys
83
+ * are ignored rather than rejected.
84
+ */
85
+ features?: Record<string, unknown>;
29
86
  highlight_matches?: boolean;
30
87
  /**
31
88
  * Ask for query suggestions alongside the results, returned as
@@ -36,9 +93,27 @@ interface SearchOptions {
36
93
  result_format?: 'standard' | 'minimal' | 'detailed';
37
94
  /** Anonymous shopper ID for personalization. Generated and persisted automatically by the SDK. */
38
95
  anonymous_id?: string;
96
+ /**
97
+ * Run the reranking stage. Omit (or `true`) for a normal search.
98
+ *
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
+ *
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.
107
+ */
108
+ rerank?: boolean;
109
+ /**
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.
112
+ */
113
+ search_id?: string;
39
114
  }
40
115
  /**
41
- * Why a result matched, as the API sends it (skawr-search#507 / #522 / #528).
116
+ * Why a result matched, as the API sends it.
42
117
  *
43
118
  * Structured, not a rendered phrase: `{type, field, term}` for a computed
44
119
  * reason and `{type: "semantic", text}` for the model-authored one. A
@@ -72,14 +147,10 @@ interface SearchResult {
72
147
  highlighted_title?: string;
73
148
  highlighted_description?: string;
74
149
  /**
75
- * Why this matched. Sent whenever the account has match chips enabled, which
76
- * is the default in production.
150
+ * Why this result matched. Render them with {@link toChips}.
77
151
  *
78
- * It was arriving all along and only reachable through the index signature
79
- * below — so every consumer that wanted to explain a result had to know the
80
- * field name and its shape without the SDK saying either. Typing it is what
81
- * makes "show why it matched" a thing the SDK offers rather than a thing you
82
- * have to already know about.
152
+ * Present when match reasons are enabled for your account, which is the
153
+ * default.
83
154
  */
84
155
  match_reasons?: MatchReason[];
85
156
  /** True when the query matched this product's text literally. */
@@ -99,14 +170,50 @@ interface Facet {
99
170
  }
100
171
  interface SearchResponse {
101
172
  results: SearchResult[];
173
+ /** How many results are pageable. NOT how many matched — see `matched_count`. */
102
174
  total_results: number;
175
+ /**
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.
179
+ */
180
+ matched_count?: number | null;
181
+ page_result_count?: number;
103
182
  page: number;
104
183
  per_page: number;
105
184
  total_pages: number;
185
+ /** Cursor for the next page; pass back as `SearchOptions.cursor`. */
186
+ next_cursor?: string | null;
187
+ /** Whether a further page exists, on the cursor path. */
188
+ has_more?: boolean | null;
106
189
  took_ms: number;
190
+ /** Time inside the search engine, excluding transport and serialisation. */
191
+ search_took_ms?: number;
107
192
  facets?: Facet[];
193
+ /** Car-specific aggregations; null on non-car queries. */
194
+ car_facets?: Record<string, unknown> | null;
195
+ dominant_bucket?: string | null;
108
196
  suggestions?: string[];
197
+ performance_metrics?: Record<string, unknown>;
198
+ analytics?: Record<string, unknown> | null;
199
+ filters_applied?: Record<string, unknown> | null;
200
+ /** What the server understood the query to be about: brand, model, category. */
201
+ query_understanding?: Record<string, unknown> | null;
202
+ api_version?: string;
109
203
  search_id: string;
204
+ /** True when the server served a degraded answer rather than a full search. */
205
+ fallback?: boolean;
206
+ cached?: boolean;
207
+ /** Whether personalization actually applied, not merely whether it was asked for. */
208
+ personalized?: boolean;
209
+ show_demand_filter?: boolean;
210
+ /**
211
+ * Fields added to the API in future reach you without an SDK upgrade.
212
+ *
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.
215
+ */
216
+ [key: string]: unknown;
110
217
  }
111
218
  interface SuggestOptions {
112
219
  limit?: number;
@@ -129,49 +236,98 @@ declare class SkawrSearch {
129
236
  private readonly publicKey;
130
237
  private readonly timeout;
131
238
  private readonly preview;
239
+ private readonly personalization;
240
+ private readonly identity;
132
241
  constructor(config: SkawrSearchConfig);
242
+ /** The stored anonymous id, or undefined when identity is off. */
243
+ private anonymousId;
133
244
  search(query: string, options?: SearchOptions): Promise<SearchResponse>;
134
245
  suggest(query: string, options?: SuggestOptions): Promise<SuggestionsResponse>;
135
246
  autocomplete(query: string, options?: AutocompleteOptions): Promise<AutocompleteResponse>;
136
247
  private get;
137
248
  private post;
249
+ /**
250
+ * The signal for one request: the caller's, this call's timeout, or both.
251
+ *
252
+ * Both matters. A debounced box aborts superseded keystrokes through its own
253
+ * signal, and still wants the timeout for the request it keeps — before this,
254
+ * `AbortSignal.timeout()` was the only signal a request could have, so a
255
+ * caller could not cancel anything.
256
+ *
257
+ * `AbortSignal.any` is the standard combinator and is present everywhere
258
+ * `AbortSignal.timeout` is (both landed together), so a caller that can time
259
+ * out can also combine.
260
+ */
261
+ private signalFor;
138
262
  private request;
139
263
  }
140
264
 
265
+ /**
266
+ * Why a search failed, in terms a UI can act on.
267
+ *
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.
270
+ *
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.
274
+ */
275
+ type ErrorKind =
276
+ /** 402 — this account's subscription is not active. */
277
+ 'subscription-expired'
278
+ /** 401/403 — the key is wrong, revoked, or not allowed from this origin. */
279
+ | 'invalid-key'
280
+ /** 429 — too many requests. Backing off and retrying may succeed. */
281
+ | 'rate-limited'
282
+ /** The request exceeded its timeout. Retrying is reasonable. */
283
+ | 'timeout'
284
+ /** The caller aborted it — a superseded keystroke, a closed page. Not a fault. */
285
+ | 'aborted'
286
+ /** 5xx — a server-side failure. A single retry is reasonable. */
287
+ | 'server-error'
288
+ /** Never reached the server at all: DNS, offline, CORS, TLS. */
289
+ | 'network'
290
+ /** A 4xx we have no specific handling for. */
291
+ | 'unknown';
141
292
  declare class SearchError extends Error {
142
293
  readonly status: number;
143
294
  readonly detail: string;
144
- constructor(message: string, status: number, detail?: string);
295
+ /**
296
+ * What kind of failure this is. Prefer branching on this over `status`:
297
+ * `aborted`, `timeout` and `network` never had a status to begin with.
298
+ */
299
+ readonly kind: ErrorKind;
300
+ constructor(message: string, status: number, detail?: string, kind?: ErrorKind);
301
+ /**
302
+ * True when you aborted this request yourself.
303
+ *
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.
306
+ */
307
+ get isAborted(): boolean;
145
308
  }
146
309
 
147
310
  /**
148
- * Turning a match reason into a chip a shopper can read
149
- * (skawr-search#507 / #522 / #528).
311
+ * Turn a result's match reasons into readable chips.
150
312
  *
151
- * The API sends `{ type, field, term }` (computed) or `{ type: "semantic", text }`
152
- * (model-authored) and no display string. That is the right shapea structured
153
- * reason survives translation and restyling, a pre-rendered English phrase does
154
- * not — but it means the wording has to live somewhere. It lives here, in the
155
- * SDK, so every surface that renders results renders the *same* explanation:
156
- * the marketplace, the hosted store page, the embeddable widget and anything a
157
- * customer builds. Before this, the only implementation was inside skawr-web,
158
- * which is why two of our own surfaces shipped bare grids while the marketplace
159
- * 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.
160
317
  *
161
- * No DOM, no framework: this returns text and two style flags, and the caller
162
- * draws them. Callers pass a `Strings` map for the computed labels (localized);
163
- * `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.
164
321
  *
165
- * Two rules the copy has to hold:
322
+ * Two rules the output holds to:
166
323
  *
167
- * 1. An unknown `type` renders as nothing rather than as something invented.
168
- * The vocabulary is open on purpose and more types may follow. A chip that
169
- * guessed at an unfamiliar type would be the one kind of reason that is not
170
- * 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.
171
327
  *
172
- * 2. `vector` says plainly that nothing in the query appeared in the product's
173
- * text. It is the chip that explains a surprising result, so it must not be
174
- * 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.
175
331
  */
176
332
 
177
333
  interface ReasonChip {
@@ -202,4 +358,4 @@ declare function toChip(reason: MatchReason, t?: Strings, query?: string): Reaso
202
358
  /** Every renderable chip for one result, in the order the API ranked them. */
203
359
  declare function toChips(reasons: MatchReason[] | undefined, t?: Strings, query?: string): ReasonChip[];
204
360
 
205
- export { type AutocompleteOptions, type AutocompleteResponse, EN_REASON_LABELS, type Facet, type FacetCount, type MatchReason, type ReasonChip, SearchError, type SearchFilters, type SearchOptions, type SearchResponse, type SearchResult, SkawrSearch, type SkawrSearchConfig, type SortBy, type Strings, type SuggestOptions, type SuggestionsResponse, toChip, toChips };
361
+ 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(e,t,r){super(e),this.name="SearchError",this.status=t,this.detail=r??e;}};var f="https://api.skawr.com",m=1e4,g="skawr_anon_id";function h(){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 y(){if(!(typeof window>"u"||typeof localStorage>"u"))try{let n=localStorage.getItem(g);return n||(n=h(),localStorage.setItem(g,n)),n}catch{return}}var l=class{baseUrl;publicKey;timeout;preview;constructor(e){if(!e.publicKey)throw new Error("publicKey is required");this.baseUrl=(e.baseUrl??f).replace(/\/+$/,""),this.publicKey=e.publicKey,this.timeout=e.timeout??m,this.preview=e.preview??false;}async search(e,t){let r=t?.anonymous_id??y();return this.post("/api/v1/search",{query:e,filters:t?.filters,page:t?.page,per_page:t?.per_page,sort_by:t?.sort_by,highlight_matches:t?.highlight_matches??true,include_suggestions:t?.include_suggestions,result_format:t?.result_format,enable_personalization:true,anonymous_id:r,shopper_id:r})}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){let r=`${this.baseUrl}${e}`;if(t){let s=new URLSearchParams(t).toString();s&&(r+=`?${s}`);}return this.request(r,{method:"GET"})}async post(e,t){return this.request(`${this.baseUrl}${e}`,{method:"POST",body:JSON.stringify(t)})}async request(e,t){let r={"X-API-Key":this.publicKey,Accept:"application/json"};this.preview&&(r["X-Skawr-Preview"]="1"),t.body&&(r["Content-Type"]="application/json");let s;try{s=await fetch(e,{...t,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 p=JSON.parse(i);a=typeof p.detail=="string"?p.detail:i;}catch{a=i||s.statusText;}throw new o(a,s.status,a)}return await s.json()}};var u={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 x(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 d(n,e=u,t=""){let r=(n.term??"").trim(),s=x(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 S(n,e=u,t=""){return Array.isArray(n)?n.map(r=>d(r,e,t)).filter(r=>r!==null):[]}
2
- export{u as EN_REASON_LABELS,o as SearchError,l as SkawrSearch,d as toChip,S as toChips};//# 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","../src/matchReasons.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","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":["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 private readonly preview: boolean;\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 }\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 include_suggestions: options?.include_suggestions,\n result_format: options?.result_format,\n enable_personalization: true,\n anonymous_id: anonId,\n // Same id, second purpose. The indexer keys its served-set on\n // `shopper_id` to make numbered pages dense and de-duplicated; without it\n // a bare ?page=N takes the legacy grid, where each page blends\n // page-specific text hits with a constant vector set. Measured at ~20% of\n // slots repeating across adjacent pages, which also means products that\n // should have appeared never do (skawr-sdks#11, skawr-search#542).\n //\n // Sent separately from `anonymous_id` rather than reusing it server-side:\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 shopper_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 // 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, {\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","/**\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.3.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",