@skawr/search 0.3.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/dist/index.d.ts CHANGED
@@ -11,6 +11,42 @@ interface SkawrSearchConfig {
11
11
  * Leave it unset on a real storefront, which is the default.
12
12
  */
13
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;
14
50
  }
15
51
  interface SearchFilters {
16
52
  min_price?: number;
@@ -21,11 +57,31 @@ interface SearchFilters {
21
57
  boost_fields?: Record<string, number>;
22
58
  }
23
59
  type SortBy = 'relevance' | 'price_asc' | 'price_desc' | 'date_desc';
24
- interface SearchOptions {
60
+ interface SearchOptions extends RequestControl {
25
61
  filters?: SearchFilters;
26
62
  page?: number;
27
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;
28
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>;
29
85
  highlight_matches?: boolean;
30
86
  /**
31
87
  * Ask for query suggestions alongside the results, returned as
@@ -36,6 +92,27 @@ interface SearchOptions {
36
92
  result_format?: 'standard' | 'minimal' | 'detailed';
37
93
  /** Anonymous shopper ID for personalization. Generated and persisted automatically by the SDK. */
38
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;
39
116
  }
40
117
  /**
41
118
  * Why a result matched, as the API sends it (skawr-search#507 / #522 / #528).
@@ -99,14 +176,56 @@ interface Facet {
99
176
  }
100
177
  interface SearchResponse {
101
178
  results: SearchResult[];
179
+ /** How many results are pageable. NOT how many matched — see `matched_count`. */
102
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;
103
188
  page: number;
104
189
  per_page: number;
105
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;
106
195
  took_ms: number;
196
+ /** Time inside the search engine, excluding transport and serialisation. */
197
+ search_took_ms?: number;
107
198
  facets?: Facet[];
199
+ /** Car-specific aggregations; null on non-car queries. */
200
+ car_facets?: Record<string, unknown> | null;
201
+ dominant_bucket?: string | null;
108
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;
109
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;
110
229
  }
111
230
  interface SuggestOptions {
112
231
  limit?: number;
@@ -129,19 +248,84 @@ declare class SkawrSearch {
129
248
  private readonly publicKey;
130
249
  private readonly timeout;
131
250
  private readonly preview;
251
+ private readonly personalization;
252
+ private readonly identity;
132
253
  constructor(config: SkawrSearchConfig);
254
+ /** The stored anonymous id, or undefined when identity is off. */
255
+ private anonymousId;
133
256
  search(query: string, options?: SearchOptions): Promise<SearchResponse>;
134
257
  suggest(query: string, options?: SuggestOptions): Promise<SuggestionsResponse>;
135
258
  autocomplete(query: string, options?: AutocompleteOptions): Promise<AutocompleteResponse>;
136
259
  private get;
137
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;
138
274
  private request;
139
275
  }
140
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';
141
312
  declare class SearchError extends Error {
142
313
  readonly status: number;
143
314
  readonly detail: string;
144
- 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;
145
329
  }
146
330
 
147
331
  /**
@@ -202,4 +386,4 @@ declare function toChip(reason: MatchReason, t?: Strings, query?: string): Reaso
202
386
  /** Every renderable chip for one result, in the order the API ranked them. */
203
387
  declare function toChips(reasons: MatchReason[] | undefined, t?: Strings, query?: string): ReasonChip[];
204
388
 
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 };
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(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 * 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.3.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",