@rudra-js/core 0.1.0 → 0.3.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.
Files changed (50) hide show
  1. package/README.md +306 -73
  2. package/dist/component-generator.d.ts +20 -0
  3. package/dist/component-generator.d.ts.map +1 -1
  4. package/dist/component-generator.js +29 -15
  5. package/dist/component-generator.js.map +1 -1
  6. package/dist/component-spec.d.ts +7 -7
  7. package/dist/component-spec.js +8 -8
  8. package/dist/component-spec.js.map +1 -1
  9. package/dist/fallback-component.d.ts +2 -1
  10. package/dist/fallback-component.d.ts.map +1 -1
  11. package/dist/fallback-component.js +2 -2
  12. package/dist/fallback-component.js.map +1 -1
  13. package/dist/fit-to-shopper.d.ts +1 -1
  14. package/dist/fit-to-shopper.d.ts.map +1 -1
  15. package/dist/fit-to-shopper.js +4 -2
  16. package/dist/fit-to-shopper.js.map +1 -1
  17. package/dist/index.d.ts +3 -5
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -5
  20. package/dist/index.js.map +1 -1
  21. package/dist/product-selection.d.ts +14 -1
  22. package/dist/product-selection.d.ts.map +1 -1
  23. package/dist/product-selection.js +10 -11
  24. package/dist/product-selection.js.map +1 -1
  25. package/dist/reconciliation.d.ts +7 -1
  26. package/dist/reconciliation.d.ts.map +1 -1
  27. package/dist/reconciliation.js +40 -16
  28. package/dist/reconciliation.js.map +1 -1
  29. package/dist/signal-digest.d.ts.map +1 -1
  30. package/dist/signal-digest.js +10 -7
  31. package/dist/signal-digest.js.map +1 -1
  32. package/dist/spec-cache.d.ts +6 -5
  33. package/dist/spec-cache.d.ts.map +1 -1
  34. package/dist/spec-cache.js +7 -0
  35. package/dist/spec-cache.js.map +1 -1
  36. package/dist/tracking-input.d.ts +5 -0
  37. package/dist/tracking-input.d.ts.map +1 -1
  38. package/dist/tracking-input.js +17 -2
  39. package/dist/tracking-input.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/component-generator.ts +54 -21
  42. package/src/component-spec.ts +8 -8
  43. package/src/fallback-component.ts +7 -3
  44. package/src/fit-to-shopper.ts +3 -1
  45. package/src/index.ts +2 -6
  46. package/src/product-selection.ts +29 -5
  47. package/src/reconciliation.ts +56 -12
  48. package/src/signal-digest.ts +10 -7
  49. package/src/spec-cache.ts +15 -5
  50. package/src/tracking-input.ts +17 -2
@@ -1,6 +1,6 @@
1
1
  import type { GeneratedSpec, ProductReference } from './component-spec.js';
2
2
  import type { SignalDigest } from './signal-digest.js';
3
- import { selectProducts, type ProductPick } from './product-selection.js';
3
+ import { selectProducts, type SelectOptions, type ProductPick } from './product-selection.js';
4
4
  import type { TrackingInput } from './tracking-input.js';
5
5
 
6
6
  /**
@@ -57,8 +57,12 @@ function toProductReference(pick: ProductPick, index: number, total: number): Pr
57
57
  * the renderer treats as "render nothing" — an empty recommendation region is
58
58
  * worse than none.
59
59
  */
60
- export function buildFallbackSpec(input: TrackingInput, digest: SignalDigest): GeneratedSpec {
61
- const picks = selectProducts(input, digest).slice(0, digest.maxItems);
60
+ export function buildFallbackSpec(
61
+ input: TrackingInput,
62
+ digest: SignalDigest,
63
+ options: SelectOptions = {},
64
+ ): GeneratedSpec {
65
+ const picks = selectProducts(input, digest, options).slice(0, digest.maxItems);
62
66
  const { headline, subheadline } = headlineFor(digest);
63
67
  const items = picks.map((pick, index) => toProductReference(pick, index, picks.length));
64
68
 
@@ -1,12 +1,13 @@
1
1
  import type { Block, GeneratedSpec, ProductReference } from './component-spec.js';
2
2
  import type { ProductPick } from './product-selection.js';
3
3
 
4
- // The model picks the shape of the component. Selection picks the products and
4
+ // The model picks the shape. Selection picks the grid and carousel products and
5
5
  // what may be said about them, so a shared component still fits one shopper.
6
6
  export function fitToShopper(
7
7
  spec: GeneratedSpec,
8
8
  picks: readonly ProductPick[],
9
9
  maxItems: number,
10
+ hostReasonSkus?: Set<string>,
10
11
  ): GeneratedSpec {
11
12
  // Picks are ordered best first. Every slot takes the next one.
12
13
  let next = 0;
@@ -24,6 +25,7 @@ export function fitToShopper(
24
25
  for (const item of block.items) {
25
26
  if (next >= limit) break; // shrink, never pad
26
27
  const chosen = picks[next]!;
28
+ if (chosen.reasonFromHost) hostReasonSkus?.add(chosen.product.sku);
27
29
  items.push({
28
30
  sku: chosen.product.sku,
29
31
  basis: chosen.basis,
package/src/index.ts CHANGED
@@ -69,10 +69,8 @@ export {
69
69
  type SpecSource,
70
70
  } from './component-spec.js';
71
71
 
72
- export { neverRecommend, reconcileSpec, type ReconcileResult } from './reconciliation.js';
72
+ export { reconcileSpec, type ReconcileResult } from './reconciliation.js';
73
73
  export { selectProducts, type ProductPick } from './product-selection.js';
74
- export { fitToShopper } from './fit-to-shopper.js';
75
- export { buildFallbackSpec } from './fallback-component.js';
76
74
 
77
75
  export {
78
76
  createFixedSpecProvider,
@@ -85,14 +83,12 @@ export {
85
83
  export {
86
84
  createMemorySpecCache,
87
85
  createNullSpecCache,
88
- cohortCacheKey,
89
- specCacheKey,
90
86
  type CachedSpec,
91
87
  type MemorySpecCacheOptions,
92
88
  type SpecCache,
93
89
  } from './spec-cache.js';
94
90
 
95
- export { SYSTEM_PROMPT, buildPrompt, type PromptPair } from './model-prompt.js';
91
+ export { buildPrompt, type PromptPair } from './model-prompt.js';
96
92
 
97
93
  export {
98
94
  createComponentGenerator,
@@ -23,6 +23,8 @@ export interface ProductPick {
23
23
  basis: RecommendationBasis;
24
24
  /** How the basis reads to a shopper. */
25
25
  reason: string;
26
+ /** True when `reason` is the host's own sentence from the candidate. */
27
+ reasonFromHost: boolean;
26
28
  /** Unnormalised. Only the ordering is meaningful. */
27
29
  score: number;
28
30
  }
@@ -95,9 +97,6 @@ function basisFor(
95
97
  if (evidence.hasCart) {
96
98
  return { basis: 'complements_cart', reason: 'Goes with what is in your cart' };
97
99
  }
98
- if ((product.rating ?? 0) >= 4.5) {
99
- return { basis: 'popular', reason: 'Highly rated' };
100
- }
101
100
  return { basis: 'popular', reason: `Popular in ${product.category}` };
102
101
  }
103
102
 
@@ -107,7 +106,24 @@ function basisFor(
107
106
  * Ties break on SKU so the order is total: two runs over the same payload
108
107
  * produce the same list, which is what makes the control arm reproducible.
109
108
  */
110
- export function selectProducts(input: TrackingInput, digest: SignalDigest): ProductPick[] {
109
+ /** How the picks are ordered once the unplaceable ones are gone. */
110
+ export type RankOrder = 'signals' | 'given';
111
+
112
+ export interface SelectOptions {
113
+ /**
114
+ * `signals` scores each candidate and orders by that score. `given` keeps the
115
+ * order you sent, for a shop whose own ranking is better than four weights.
116
+ * Either way the exclusions and the stock check still apply, and every pick
117
+ * still carries a basis reconciliation can verify.
118
+ */
119
+ rank?: RankOrder;
120
+ }
121
+
122
+ export function selectProducts(
123
+ input: TrackingInput,
124
+ digest: SignalDigest,
125
+ options: SelectOptions = {},
126
+ ): ProductPick[] {
111
127
  const affinityByCategory = new Map(
112
128
  digest.categoryAffinity.map((affinity) => [affinity.category, affinity.score]),
113
129
  );
@@ -144,9 +160,17 @@ export function selectProducts(input: TrackingInput, digest: SignalDigest): Prod
144
160
  hasCart,
145
161
  });
146
162
 
147
- picks.push({ product, basis, reason, score });
163
+ picks.push({
164
+ product,
165
+ basis,
166
+ reason: product.reason ?? reason,
167
+ reasonFromHost: product.reason !== undefined,
168
+ score,
169
+ });
148
170
  }
149
171
 
172
+ if (options.rank === 'given') return picks;
173
+
150
174
  return picks.toSorted(
151
175
  (left, right) => right.score - left.score || left.product.sku.localeCompare(right.product.sku),
152
176
  );
@@ -40,6 +40,8 @@ const CLAMP = {
40
40
  bannerText: 160,
41
41
  copyBody: 420,
42
42
  rationale: 300,
43
+ // Not rendered anywhere — this is the SKU as it reads back in a violation.
44
+ violationSku: 32,
43
45
  } as const;
44
46
 
45
47
  /**
@@ -175,6 +177,8 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
175
177
  /\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
176
178
  // A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
177
179
  /\bratings?\s+of\s+[0-5]\.\d\b/,
180
+ // The same score with the word "rating" nowhere near it: "4.8 out of 5".
181
+ /\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
178
182
  /\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
179
183
  // "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
180
184
  /\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
@@ -186,8 +190,12 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
186
190
  {
187
191
  kind: 'price',
188
192
  patterns: [
189
- /[$£€¥]\s?\d/,
193
+ /[$£€¥₹]\s?\d/,
190
194
  /\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
195
+ // The same codes on the other side of the number: "USD 20", "EUR 5.99".
196
+ /\b(?:usd|eur|gbp)\s?\d/,
197
+ // The krona is spelled out rather than drawn, so it needs a number beside it.
198
+ /\bkr\s?\d|\d\s?kr\b/,
191
199
  /\bpric(?:e|es|ed|ing)\b/,
192
200
  // A before-and-after is a price claim even with no currency on it.
193
201
  /\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
@@ -212,6 +220,8 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
212
220
  /\bdiscount(?:s|ed)?\b/,
213
221
  /\bsale\b|\bmarked down\b|\bdeal of\b/,
214
222
  /\bhalf[\s-]?(?:price|off)\b/,
223
+ // Money off with no percent sign anywhere on it: "save 20 off".
224
+ /\bsaves?\s+\d[\d.,]*\s+off\b/,
215
225
  // "extra clearance for thick socks" is room inside the shoe.
216
226
  /\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
217
227
  // "reduced weight" and "reduced to 900g" are specifications. Only a
@@ -236,10 +246,10 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
236
246
  kind: 'stock',
237
247
  patterns: [
238
248
  /\b(?:in|out of|low on) stock\b/,
249
+ /\blimited stock\b/,
239
250
  /\brestocked?\b|\bsold out\b/,
240
251
  // "the last few miles" is a distance, so a count needs "left" after it.
241
- /\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remaining)\b/,
242
- /\blast (?:one|few)\s+(?:left|remaining|in stock)\b/,
252
+ /\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
243
253
  /\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
244
254
  ],
245
255
  },
@@ -329,11 +339,14 @@ function screenRequired(value: string, field: string, tracker: PlacementTracker)
329
339
  * so each one has to name the fault that actually fired.
330
340
  */
331
341
  function rejectionFor(sku: string, allowlist: Allowlist, tracker: PlacementTracker): string | null {
342
+ // A rejected SKU is whatever the model wrote, and the schema cannot bound it.
343
+ const named = clamp(sku, CLAMP.violationSku);
344
+
332
345
  // Either hallucinated or out of stock. Either way it cannot render.
333
- if (!allowlist.allowed.has(sku)) return `unknown-sku:${sku}`;
334
- if (allowlist.blocked.has(sku)) return `blocked-sku:${sku}`;
335
- if (tracker.hasPlaced(sku)) return `duplicate-sku:${sku}`;
336
- if (tracker.remaining <= 0) return `budget:dropped:${sku}`;
346
+ if (!allowlist.allowed.has(sku)) return `unknown-sku:${named}`;
347
+ if (allowlist.blocked.has(sku)) return `blocked-sku:${named}`;
348
+ if (tracker.hasPlaced(sku)) return `duplicate-sku:${named}`;
349
+ if (tracker.remaining <= 0) return `budget:dropped:${named}`;
337
350
  return null;
338
351
  }
339
352
 
@@ -343,6 +356,7 @@ function reconcileItems(
343
356
  candidatesBySku: Map<string, Product>,
344
357
  digest: SignalDigest,
345
358
  tracker: PlacementTracker,
359
+ hostReasonSkus: ReadonlySet<string>,
346
360
  ): ProductReference[] {
347
361
  const kept: ProductReference[] = [];
348
362
 
@@ -362,15 +376,23 @@ function reconcileItems(
362
376
  const hasSupportedBasis = verifyBasis(item.basis, product, digest);
363
377
  if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
364
378
 
379
+ const isHostReason =
380
+ item.reason !== null && hostReasonSkus.has(item.sku) && item.reason === product.reason;
381
+
365
382
  kept.push({
366
383
  sku: item.sku,
367
384
  basis: hasSupportedBasis ? item.basis : 'popular',
368
385
  // The prose exists to state the basis. If the basis did not hold, the
369
386
  // prose is a claim we just decided is untrue.
370
387
  reason: hasSupportedBasis
371
- ? screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker)
388
+ ? isHostReason
389
+ ? clampNullable(item.reason, CLAMP.reason)
390
+ : screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker)
372
391
  : null,
373
- badge: clampNullable(item.badge, CLAMP.badge),
392
+ // A badge is the shortest, loudest text on the card, and the schema's own
393
+ // example for it was "Back in stock" — a stock claim. It renders, so it is
394
+ // read for claims like every other sentence the model writes.
395
+ badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker),
374
396
  emphasis: item.emphasis,
375
397
  });
376
398
  }
@@ -503,6 +525,7 @@ function reconcileBlock(
503
525
  digest: SignalDigest,
504
526
  bundles: readonly Bundle[],
505
527
  tracker: PlacementTracker,
528
+ hostReasonSkus: ReadonlySet<string>,
506
529
  ): Block | null {
507
530
  switch (block.kind) {
508
531
  case 'hero': {
@@ -539,7 +562,14 @@ function reconcileBlock(
539
562
  }
540
563
 
541
564
  case 'grid': {
542
- const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker);
565
+ const items = reconcileItems(
566
+ block.items,
567
+ allowlist,
568
+ candidatesBySku,
569
+ digest,
570
+ tracker,
571
+ hostReasonSkus,
572
+ );
543
573
  if (items.length === 0) {
544
574
  tracker.record('empty-block:grid');
545
575
  return null;
@@ -554,7 +584,14 @@ function reconcileBlock(
554
584
  }
555
585
 
556
586
  case 'carousel': {
557
- const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker);
587
+ const items = reconcileItems(
588
+ block.items,
589
+ allowlist,
590
+ candidatesBySku,
591
+ digest,
592
+ tracker,
593
+ hostReasonSkus,
594
+ );
558
595
  if (items.length === 0) {
559
596
  tracker.record('empty-block:carousel');
560
597
  return null;
@@ -630,6 +667,12 @@ export function reconcileSpec(
630
667
  generated: GeneratedSpec,
631
668
  input: TrackingInput,
632
669
  digest: SignalDigest,
670
+ /**
671
+ * SKUs whose reason this request wrote from the host's own candidate. Only
672
+ * `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
673
+ * reason is the model's, including one that happens to read the same.
674
+ */
675
+ hostReasonSkus: ReadonlySet<string> = new Set(),
633
676
  ): ReconcileResult {
634
677
  const allowlist = buildAllowlist(input, digest);
635
678
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
@@ -648,6 +691,7 @@ export function reconcileSpec(
648
691
  digest,
649
692
  input.bundles,
650
693
  tracker,
694
+ hostReasonSkus,
651
695
  );
652
696
  if (reconciled !== null) blocks.push(reconciled);
653
697
  }
@@ -661,7 +705,7 @@ export function reconcileSpec(
661
705
  tracker,
662
706
  ),
663
707
  blocks,
664
- rationale: clamp(generated.rationale, CLAMP.rationale),
708
+ rationale: screenRequired(clamp(generated.rationale, CLAMP.rationale), 'rationale', tracker),
665
709
  };
666
710
 
667
711
  // A component that recommends nothing is worse than no component at all.
@@ -230,14 +230,17 @@ function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
230
230
 
231
231
  /** The most-viewed few, as the digest reports them. */
232
232
  function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
233
- return mergeViewsBySku(views)
233
+ const top = mergeViewsBySku(views)
234
234
  .toSorted((left, right) => right.views - left.views)
235
- .slice(0, DIGEST_LIMITS.viewed)
236
- .map(({ sku, views: viewCount, dwellMs }) => ({
237
- sku,
238
- views: viewCount,
239
- ...(dwellMs !== undefined ? { dwellMs } : {}),
240
- }));
235
+ .slice(0, DIGEST_LIMITS.viewed);
236
+
237
+ const products: ViewedProduct[] = [];
238
+ for (const merged of top) {
239
+ const product: ViewedProduct = { sku: merged.sku, views: merged.views };
240
+ if (merged.dwellMs !== undefined) product.dwellMs = merged.dwellMs;
241
+ products.push(product);
242
+ }
243
+ return products;
241
244
  }
242
245
 
243
246
  /** The open-vocabulary long tail, reduced to "which kinds, and how often". */
package/src/spec-cache.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { SPEC_VERSION, type GeneratedSpec } from './component-spec.js';
3
+ import { SYSTEM_PROMPT } from './model-prompt.js';
3
4
  import type { SignalDigest } from './signal-digest.js';
4
5
 
5
6
  /**
@@ -15,11 +16,6 @@ import type { SignalDigest } from './signal-digest.js';
15
16
  * instance count.
16
17
  */
17
18
 
18
- /**
19
- * Asynchronous on purpose. A synchronous `get` would look simpler and would
20
- * make the shared store the docs recommend impossible to write, since no Redis
21
- * or Memcached client can return a value without awaiting.
22
- */
23
19
  /**
24
20
  * A stored generation, with the moment it was produced.
25
21
  *
@@ -33,9 +29,15 @@ export interface CachedSpec {
33
29
  generatedAt: number;
34
30
  }
35
31
 
32
+ /**
33
+ * Asynchronous on purpose. A synchronous `get` would look simpler and would
34
+ * make the shared store the docs recommend impossible to write, since no Redis
35
+ * or Memcached client can return a value without awaiting.
36
+ */
36
37
  export interface SpecCache {
37
38
  get(key: string): Promise<CachedSpec | undefined>;
38
39
  set(key: string, cached: CachedSpec): Promise<void>;
40
+ delete?(key: string): Promise<void>;
39
41
  }
40
42
 
41
43
  export interface MemorySpecCacheOptions {
@@ -115,6 +117,10 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
115
117
  entries.delete(oldest.value);
116
118
  }
117
119
  },
120
+
121
+ async delete(key) {
122
+ entries.delete(key);
123
+ },
118
124
  };
119
125
  }
120
126
 
@@ -148,6 +154,8 @@ function canonicalise(value: unknown): string {
148
154
  return `{${entries.map(([name, fieldValue]) => `${JSON.stringify(name)}:${canonicalise(fieldValue)}`).join(',')}}`;
149
155
  }
150
156
 
157
+ const PROMPT_FINGERPRINT = createHash('sha256').update(SYSTEM_PROMPT).digest('hex').slice(0, 16);
158
+
151
159
  /**
152
160
  * Derives the cache key.
153
161
  *
@@ -182,6 +190,7 @@ export function specCacheKey(
182
190
  // The spec's own version, so a shape change cannot read entries written by
183
191
  // the previous shape out of a shared store that outlives a deploy.
184
192
  specVersion: SPEC_VERSION,
193
+ prompt: PROMPT_FINGERPRINT,
185
194
  provider: providerId,
186
195
  digest,
187
196
  candidates: candidateSkus.toSorted(),
@@ -201,6 +210,7 @@ export function cohortCacheKey(
201
210
  ): string {
202
211
  const material = canonicalise({
203
212
  specVersion: SPEC_VERSION,
213
+ prompt: PROMPT_FINGERPRINT,
204
214
  provider: providerId,
205
215
  segment: digest.segment ?? null,
206
216
  surface: digest.surface,
@@ -42,6 +42,11 @@ export const FIELD_LIMITS = {
42
42
  candidates: 200,
43
43
  productsPerBundle: 5,
44
44
  bundles: 20,
45
+ localeTag: 35,
46
+ maxItems: 12,
47
+ // Matches CLAMP.reason in reconciliation: a host reason is rendered in the
48
+ // same place a model's is, so the same length has to hold.
49
+ reason: 120,
45
50
  } as const;
46
51
 
47
52
  /** Assigning this as an object key mutates the prototype instead of the object. */
@@ -93,6 +98,10 @@ export const productSchema = z.strictObject({
93
98
  // scheme to abuse.
94
99
  imageUrl: imageReference().optional(),
95
100
  rating: z.number().min(0).max(5).optional(),
101
+ // Your phrase for why this product is here, when your own ranking already has
102
+ // one. It renders as the item's reason and is not screened: these are your
103
+ // words about your product, like the title, so you stand behind them.
104
+ reason: z.string().min(1).max(FIELD_LIMITS.reason).optional(),
96
105
  isInStock: z.boolean().default(true),
97
106
  tags: z
98
107
  .array(z.string().min(1).max(FIELD_LIMITS.tag))
@@ -179,9 +188,15 @@ export const renderContextSchema = z.strictObject({
179
188
  currentSku: optionalIdentifier(),
180
189
  currentCategory: optionalIdentifier(),
181
190
  searchQuery: z.string().max(FIELD_LIMITS.searchQuery).optional(),
182
- locale: z.string().min(2).max(35).default('en-US'),
191
+ // One language tag, not the Accept-Language header it is often copied from:
192
+ // the locale is part of the cohort cache key, so a list makes its own cohort.
193
+ locale: z
194
+ .string()
195
+ .max(FIELD_LIMITS.localeTag)
196
+ .regex(/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, 'expected one language tag, such as en-US')
197
+ .default('en-US'),
183
198
  /** Upper bound on products across the whole generated component. */
184
- maxItems: z.number().int().min(1).max(12).default(4),
199
+ maxItems: z.number().int().min(1).max(FIELD_LIMITS.maxItems).default(4),
185
200
  });
186
201
  export type RenderContext = z.infer<typeof renderContextSchema>;
187
202