@rudra-js/a2ui 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,10 +8,12 @@ It is not a hosted platform, a visual or low-code app builder, or a workspace yo
8
8
 
9
9
  This package puts that block inside the interfaces an [A2UI](https://a2ui.org) agent builds. It
10
10
  adds one component, `RudraRecommendations`, to the catalog your A2UI renderer draws from. The agent
11
- places it and hints at what the shopper is looking at. Your server still picks the products and
12
- builds the block with `@rudra-js/core`, and the browser renders it with `@rudra-js/react`.
11
+ places it and passes hints about what the shopper is looking at. Your server still picks the
12
+ products and builds the block with `@rudra-js/core`. The browser renders it with `@rudra-js/react`.
13
+ For Flutter, Angular or any other renderer, your server can send the block as A2UI messages
14
+ instead. See [Flutter, Angular and other renderers](#flutter-angular-and-other-renderers).
13
15
 
14
- It works with version 0.9 of the A2UI protocol, through `@a2ui/react` and `@a2ui/web_core` 0.14.
16
+ It works with version 0.9 of the A2UI protocol, using `@a2ui/react` and `@a2ui/web_core` 0.14.
15
17
 
16
18
  ## Install
17
19
 
@@ -19,28 +21,33 @@ It works with version 0.9 of the A2UI protocol, through `@a2ui/react` and `@a2ui
19
21
  npm install @rudra-js/a2ui@latest @rudra-js/core@latest @rudra-js/react@latest @rudra-js/attested@latest @a2ui/react@0.14 @a2ui/web_core@0.14 react react-dom
20
22
  ```
21
23
 
22
- Run the same command in a new app, in one that already uses rudra-js or A2UI, and to upgrade. If
23
- your app has `@rudra-js/anthropic`, add `@rudra-js/anthropic@latest` to the command. The
24
- `@rudra-js` packages are published together, and each one's peer ranges on the others start at its
25
- own version. For a bare name npm stays inside the range your `package.json` already has, so
26
- without `@latest` an app on an older core can get an `ERESOLVE`. `@0.14` is the `@a2ui` release
27
- this package works with. `react` and `react-dom` carry no version, so npm and pnpm keep a React 18
28
- app on 18.
24
+ Use the same command to install and to upgrade, whether or not your app already uses rudra-js or A2UI.
25
+ If your app has `@rudra-js/anthropic`, add `@rudra-js/anthropic@latest` to that command.
29
26
 
30
- Everything but zod is a peer dependency, so your app holds one copy of each. npm and pnpm add
31
- peers for you, but your code imports most of them itself, and pnpm lets it import only what your
32
- `package.json` names. Yarn doesn't add peers, so there you need the whole list. It does move a bare
33
- name to its latest version, so leave out `react` and `react-dom` if your app already has React. On
34
- pnpm, read [zod](#zod) first.
27
+ The `@rudra-js` packages are released together. Each one's peer range for the others starts at its
28
+ own version. For a package name with no version, npm stays inside the range your `package.json`
29
+ already has. Without `@latest`, an app on an older core can get an `ERESOLVE` error. `react`
30
+ and `react-dom` have no version, so npm and pnpm keep a React 18 app on 18.
35
31
 
36
- Keep `skipLibCheck: true`. Without it, A2UI's own declarations fail to typecheck: they need `"dom"`
37
- in `lib`, and two of their imports have no file extension, which `nodenext` refuses.
32
+ Everything except zod is a peer dependency, so your app holds one copy of each. npm and pnpm
33
+ install `@rudra-js/core`, `@rudra-js/attested` and `@a2ui/web_core` for you, but not `@a2ui/react`,
34
+ `@rudra-js/react` or `react`. Those three are optional, so a server that only builds
35
+ [messages for other renderers](#flutter-angular-and-other-renderers) can leave React out. The
36
+ browser component can't load without them, so keep them in the command. List the rest anyway,
37
+ because your code imports most of them itself.
38
+ pnpm only lets your code import what your `package.json` names. Yarn doesn't install peers, so on Yarn you
39
+ need the whole list. Yarn also moves a name with no version to its latest release. So on Yarn, if your app
40
+ already has React, leave out `react` and `react-dom`. On pnpm, read [zod](#zod) first.
41
+
42
+ Keep `skipLibCheck: true` in your `tsconfig.json`. Without it, A2UI's own type declarations fail
43
+ to typecheck. They need `"dom"` in `lib`. Two of their imports also have no file extension, which
44
+ `nodenext` refuses.
38
45
 
39
46
  ## Three pieces
40
47
 
41
48
  ### The catalog your agent is prompted with
42
49
 
43
- Put `agentCatalog.toJson()` in your agent's prompt and have it create surfaces with
50
+ Put `agentCatalog.toJson()` in your agent's prompt. Have the agent create surfaces with
44
51
  `catalogId: agentCatalog.id`. If you have catalog instructions of your own, join them to
45
52
  `RUDRA_RECOMMENDATIONS_INSTRUCTIONS`.
46
53
 
@@ -58,13 +65,16 @@ export const agentCatalog = new Catalog(
58
65
  );
59
66
  ```
60
67
 
61
- The first `toJson()` logs one "Recursive reference detected" warning per hint. It's harmless.
68
+ The first `toJson()` call logs one "Recursive reference detected" warning per hint. It's harmless.
62
69
 
63
70
  ### Your endpoint
64
71
 
65
- `shopperFor` and `candidatesFor` stand for the session and catalog code you already have.
66
- `provider: null` calls no model, so pass your own provider. In the default cohort mode the model
67
- never sees `searchQuery`, so the agent's search only counts with `generation: 'per-shopper'`.
72
+ `shopperFor` and `candidatesFor` stand in for the session and catalog code you already have.
73
+ `provider: null` calls no model, so pass your own provider.
74
+
75
+ `generation` defaults to `'cohort'`, which shares one block design between shoppers who look alike. In
76
+ that mode the model never sees `searchQuery`. The agent's search only matters with
77
+ `generation: 'per-shopper'`.
68
78
 
69
79
  ```ts
70
80
  import { createComponentGenerator, parseTrackingInput } from '@rudra-js/core';
@@ -103,11 +113,12 @@ export async function POST(request: Request): Promise<Response> {
103
113
  ```
104
114
 
105
115
  - `parseRecommendationRequest` accepts only `currentSku`, `currentCategory`, `searchQuery` and
106
- `maxItems`. Anything else is a 400.
107
- - `fitRecommendationRequest` drops a SKU or category none of your candidates has.
108
- - Pick candidates on your side, not from the hints. Each new category, `maxItems` or candidate set
109
- is a new model call, and with `per-shopper` so is each shopper and each new SKU or search, so
110
- rate-limit the endpoint (see [SECURITY.md](https://github.com/clivedsouza1010/rudra-js/blob/main/SECURITY.md)).
116
+ `maxItems`. Anything else gets a 400.
117
+ - `fitRecommendationRequest` drops a SKU or category that none of your candidates has.
118
+ - Pick candidates on your side, not from the hints.
119
+ - Each new category, `maxItems` or candidate set means a new model call. With `per-shopper`, so
120
+ does each shopper and each new SKU or search. Rate-limit the endpoint (see
121
+ [SECURITY.md](https://github.com/clivedsouza1010/rudra-js/blob/main/SECURITY.md)).
111
122
  - Send back `input.candidates` and `input.bundles`, not your raw catalog.
112
123
 
113
124
  ### The browser
@@ -142,51 +153,125 @@ const shopCatalog = new Catalog<ReactCatalogComponent>(
142
153
  export const processor = new MessageProcessor<ReactCatalogComponent>([shopCatalog]);
143
154
  ```
144
155
 
145
- Give the browser catalog the same id as the agent catalog, because A2UI looks it up by the
146
- `catalogId` the agent sends. Render the processor's surfaces with `A2uiSurface`. The options take
147
- `loadRecommendations`, `placeholder`, `onError`, and every `RudraComponent` prop except `spec`,
148
- `products` and `bundles`.
156
+ Give the browser catalog the same id as the agent catalog. A2UI looks it up by the `catalogId` the
157
+ agent sends. Render the processor's surfaces with `A2uiSurface`.
158
+
159
+ `createRudraRecommendationsComponent` takes `loadRecommendations`, `placeholder` and `onError`. It
160
+ also takes every `RudraComponent` prop except `spec`, `products` and `bundles`.
149
161
 
150
162
  ## What the component does
151
163
 
152
- - It reads each hint as a literal or from the agent's data model (`{ "path": "/viewing/sku" }`),
153
- and sends only the four. It leaves out a blank one, and one the server would turn away, like a
154
- `maxItems` of 40. A literal of the wrong type, like a numeric SKU, makes A2UI refuse the agent's
155
- whole message.
156
- - It asks once the hints hold still for 300 ms, and keeps the last block up until the next one
157
- arrives. `signal` aborts when the hints change or the component unmounts.
158
- - It checks the result against core's schemas before rendering any of it.
159
- - It shows `placeholder` until the first load. On a failed load, a bad result or a render error it
160
- shows nothing and calls `onError`.
164
+ - It reads each hint either as a literal value or from the agent's data model, like
165
+ `{ "path": "/viewing/sku" }`. It sends only the four hints the endpoint accepts.
166
+ - It leaves out a hint that is blank, or one the server would turn away, like a `maxItems` of 40.
167
+ - A literal of the wrong type, like a numeric SKU, makes A2UI refuse the agent's whole message.
168
+ - It waits until the hints stop changing for 300 ms, then calls `loadRecommendations`.
169
+ - It keeps showing the last block until the next one arrives.
170
+ - `signal` aborts when the hints change or the component unmounts.
171
+ - It checks the result against core's schemas before it renders any of it.
172
+ - It shows `placeholder` until the first load finishes.
173
+ - If a load fails or the result fails the schema check, it shows nothing and calls `onError`. It
174
+ does the same if the block throws while rendering.
161
175
  - Each component on a surface sends its own request.
162
176
 
163
177
  ## Where it runs
164
178
 
165
- `@rudra-js/a2ui` holds `createRudraRecommendationsComponent` and its types, and loads only in the
179
+ `@rudra-js/a2ui` holds `createRudraRecommendationsComponent` and its types. It loads only in the
166
180
  browser, because `@a2ui/react` needs a DOM. The agent catalog and your endpoint import from
167
- `@rudra-js/a2ui/server`, which runs anywhere. In Next.js a `'use client'` file still runs on the
168
- server for the first render, so load the one that imports `@rudra-js/a2ui` from a client component
169
- with `next/dynamic` and `ssr: false`.
181
+ `@rudra-js/a2ui/server`, which runs anywhere.
170
182
 
171
- A2UI renders on the client, so a crawler won't see a block an agent places. For a product page,
183
+ In Next.js, a `'use client'` file still runs on the server for the first render. Load the file
184
+ that imports `@rudra-js/a2ui` through `next/dynamic` with `ssr: false`, and make that call from a
185
+ client component.
186
+
187
+ A2UI renders in the browser, so a crawler won't see a block an agent places. For a product page,
172
188
  render `RudraComponent` on the server, as the
173
189
  [`@rudra-js/react` README](https://github.com/clivedsouza1010/rudra-js/tree/main/packages/react)
174
190
  describes.
175
191
 
192
+ ## Flutter, Angular and other renderers
193
+
194
+ `RudraRecommendations` needs a React renderer. For any other A2UI renderer, build the block on your
195
+ server as A2UI messages that use only the basic catalog, which every renderer ships. A server with
196
+ no React needs only these:
197
+
198
+ ```sh
199
+ npm install @rudra-js/a2ui@latest @rudra-js/core@latest @rudra-js/attested@latest @a2ui/web_core@0.14
200
+ ```
201
+
202
+ If your server has `@rudra-js/anthropic`, add `@rudra-js/anthropic@latest` to that command too.
203
+
204
+ ```ts
205
+ import { buildRecommendationMessages, type RecommendationResult } from '@rudra-js/a2ui/server';
206
+
207
+ export function messagesFor(result: RecommendationResult, locale: string) {
208
+ return buildRecommendationMessages(result, {
209
+ surfaceId: 'rudra_recommendations',
210
+ shopUrl: 'https://shop.example',
211
+ locale,
212
+ });
213
+ }
214
+ ```
215
+
216
+ In [your endpoint](#your-endpoint), return `Response.json(messagesFor(result, shopper.locale))`
217
+ instead of `result`. Skip the [agent catalog](#the-catalog-your-agent-is-prompted-with) and
218
+ [browser](#the-browser) pieces, and keep `rudraRecommendationsApi` out of your agent's catalog.
219
+ Those are for `RudraRecommendations`, which needs React. Use your own `surfaceId` in place of
220
+ `'rudra_recommendations'`, and never name it in your agent's prompt.
221
+
222
+ Your app can call the endpoint with the four hints and give the messages it gets back to its A2UI
223
+ renderer. Or your agent's code can call it and pass the messages on to the client unchanged. Then
224
+ the call has to carry the shopper's session, because `shopperFor` reads the shopper from the
225
+ request. The first way is the safer one. Either way, keep the model away from the messages and
226
+ from the block's surface, as
227
+ [SECURITY.md](https://github.com/clivedsouza1010/rudra-js/blob/main/SECURITY.md) explains.
228
+
229
+ - The messages create the surface named `surfaceId` and fill it. To show a new block there, send
230
+ `deleteSurface` for it first. When nothing in the block can be shown, you get no messages.
231
+ - Grids and carousels show two cards to a row. The basic catalog can't fit cards to the screen, and
232
+ its `List` doesn't scroll on every renderer.
233
+ - Each product card is a button that opens the product's page. `hrefForSku` builds the link,
234
+ `/product/<sku>` by default. `shopUrl` resolves that link and any relative image URL, because a
235
+ native app has no page to resolve them against. A product whose image URL is empty or only
236
+ spaces shows no image. A link that is empty, or a link or an image that doesn't end up `http` or
237
+ `https`, throws.
238
+ - A featured card looks like any other, and a banner looks the same whatever its tone, because the
239
+ basic catalog has no styles for them. Nothing carries the `data-rudra-*` attributes the React
240
+ block uses for click tracking, so to count clicks, add a query parameter in `hrefForSku`.
241
+ - Prices are formatted on your server, for `locale`. Without it, they use the server's default
242
+ locale, not the shopper's. Pass `formatPrice` and `formatBundlePrice` to format them yourself.
243
+ - All text uses a heading or caption style, never body, because the web renderers read only body
244
+ text as Markdown. Flutter's `genui` and the A2UI SwiftUI renderer read every style as Markdown, as
245
+ the
246
+ [A2UI guide](https://github.com/a2ui-project/a2ui/blob/main/specification/v0_9_1/docs/basic_catalog_implementation_guide.md)
247
+ asks. So square brackets become parentheses, `<` and `>` become `‹` and `›`, and line breaks
248
+ become spaces. No text can load an image, and only a bare web address or email address can show
249
+ as a link. On SwiftUI a tap opens it. On `genui` it does nothing, and text that starts with `# `
250
+ or `- ` shows as Markdown.
251
+ - The rationale and how the block was generated are never sent.
252
+ - Flutter's `genui` knows the basic catalog by its id from 0.10.3. For 0.8 to 0.10.2, pass
253
+ `catalogId: 'https://a2ui.org/specification/v0_9/basic_catalog.json'`. On Android 11 and later,
254
+ the app has to declare `https` links under `<queries>` in its manifest, or a tap on a card does
255
+ nothing.
256
+ - On `@a2ui/angular` 0.13, register the `BasicCatalog` from `@a2ui/angular/v0_9`, because the one
257
+ from `@a2ui/angular` has the v1.0 id. Pass `useUniversalComponents: true` to `provideA2Ui` too,
258
+ next to `catalogs`. Without it, a click on a card does nothing, and each card tries to open its
259
+ link in a new tab as soon as it renders.
260
+
176
261
  ## zod
177
262
 
178
- A2UI's schemas are zod 3, so this package depends on zod 3 while the rest of rudra-js uses zod 4.
179
- npm installs both, each where its package finds it, and you don't need either one yourself.
263
+ A2UI's schemas use zod 3, so this package depends on zod 3. The rest of rudra-js uses zod 4. npm
264
+ installs both, each where its own package finds it. With npm, you don't need either one yourself.
180
265
 
181
- On pnpm, `@a2ui/react` gets core's zod 4 as its zod peer, and the browser example stops
182
- typechecking (TS2344). If your app has no zod of its own, add zod 3:
266
+ On pnpm, `@a2ui/react` gets core's zod 4 as its zod peer. Then the browser example fails to
267
+ typecheck with TS2344. If your app has no zod of its own, add zod 3:
183
268
 
184
269
  ```sh
185
270
  pnpm add zod@^3.25.76
186
271
  ```
187
272
 
188
- If your app uses zod 4, drop the peer from `@a2ui/react` in `.pnpmfile.cjs`, and pnpm gives it
189
- the zod 3 it depends on:
273
+ If your app uses zod 4, remove the zod peer from `@a2ui/react` in `.pnpmfile.cjs`. pnpm then gives
274
+ it the zod 3 it depends on:
190
275
 
191
276
  ```js
192
277
  module.exports = {
@@ -0,0 +1,14 @@
1
+ import type { A2uiMessage } from '@a2ui/web_core/v0_9';
2
+ import type { Bundle, Product } from '@rudra-js/core';
3
+ import type { RecommendationResult } from './recommendation-result.js';
4
+ export interface RecommendationMessagesOptions {
5
+ surfaceId: string;
6
+ shopUrl: string;
7
+ catalogId?: string | undefined;
8
+ hrefForSku?: ((sku: string) => string) | undefined;
9
+ formatPrice?: ((product: Product) => string) | undefined;
10
+ formatBundlePrice?: ((bundle: Bundle) => string) | undefined;
11
+ locale?: string | undefined;
12
+ }
13
+ export declare function buildRecommendationMessages(result: RecommendationResult, options: RecommendationMessagesOptions): A2uiMessage[];
14
+ //# sourceMappingURL=recommendation-messages.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recommendation-messages.d.ts","sourceRoot":"","sources":["../src/recommendation-messages.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAGV,MAAM,EAIN,OAAO,EAER,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAEvE,MAAM,WAAW,6BAA6B;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,UAAU,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,SAAS,CAAC;IACnD,WAAW,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC,GAAG,SAAS,CAAC;IACzD,iBAAiB,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,SAAS,CAAC;IAC7D,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC7B;AAuTD,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,6BAA6B,GACrC,WAAW,EAAE,CAoCf"}
@@ -0,0 +1,242 @@
1
+ const BASIC_CATALOG_ID = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json';
2
+ const CARDS_PER_ROW = 2;
3
+ function defaultHrefForSku(sku) {
4
+ return `/product/${encodeURIComponent(sku)}`;
5
+ }
6
+ function formatMoney(price, currency, locale) {
7
+ let formatter;
8
+ try {
9
+ formatter = new Intl.NumberFormat(locale, { style: 'currency', currency });
10
+ }
11
+ catch {
12
+ return `${currency} ${price}`;
13
+ }
14
+ return formatter.format(price);
15
+ }
16
+ function defaultFormatPrice(product, locale) {
17
+ if (!Number.isFinite(product.price)) {
18
+ throw new TypeError(`price for SKU ${product.sku} is ${String(product.price)}, not a finite number. ` +
19
+ 'Catalog objects must satisfy productSchema from @rudra-js/core');
20
+ }
21
+ return formatMoney(product.price, product.currency, locale);
22
+ }
23
+ function defaultFormatBundlePrice(bundle, locale) {
24
+ if (!Number.isFinite(bundle.price)) {
25
+ throw new TypeError(`price for bundle ${bundle.id} is ${String(bundle.price)}, not a finite number. ` +
26
+ 'Bundle objects must satisfy bundleSchema from @rudra-js/core');
27
+ }
28
+ return formatMoney(bundle.price, bundle.currency, locale);
29
+ }
30
+ function toWebUrl(value, base, name) {
31
+ if (typeof value !== 'string')
32
+ throw new TypeError(`${name} is not a string`);
33
+ if (value.trim() === '')
34
+ throw new TypeError(`${name} is empty`);
35
+ let url;
36
+ try {
37
+ url = new URL(value, base);
38
+ }
39
+ catch {
40
+ throw new TypeError(`${name} is not a URL: ${value}`);
41
+ }
42
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
43
+ throw new TypeError(`${name} is not an http or https URL: ${value}`);
44
+ }
45
+ return url.href;
46
+ }
47
+ function plainText(value) {
48
+ return value
49
+ .replace(/[\t\n\v\f\r \u0085\u2028\u2029]+/g, ' ')
50
+ .trim()
51
+ .replaceAll('[', '(')
52
+ .replaceAll(']', ')')
53
+ .replaceAll('<', '‹')
54
+ .replaceAll('>', '›');
55
+ }
56
+ function defined(ids) {
57
+ return ids.filter((id) => id !== undefined);
58
+ }
59
+ function sellableProduct(context, sku) {
60
+ const product = context.products.get(sku);
61
+ return product?.isInStock === false ? undefined : product;
62
+ }
63
+ function addText(context, id, value, variant) {
64
+ const text = value === null ? '' : plainText(value);
65
+ return text ? context.add({ id, component: 'Text', text, variant }) : undefined;
66
+ }
67
+ function addStartColumn(context, id, children) {
68
+ return context.add({ id, component: 'Column', align: 'start', children: defined(children) });
69
+ }
70
+ function addColumn(context, id, children) {
71
+ return context.add({ id, component: 'Column', children: defined(children) });
72
+ }
73
+ function addLink(context, id, sku, child) {
74
+ const url = context.linkFor(sku);
75
+ return context.add({
76
+ id,
77
+ component: 'Button',
78
+ child,
79
+ variant: 'borderless',
80
+ action: { functionCall: { call: 'openUrl', args: { url }, returnType: 'void' } },
81
+ });
82
+ }
83
+ function addLinkedCard(context, id, sku, children, weight) {
84
+ const link = addLink(context, `${id}_link`, sku, addStartColumn(context, `${id}_body`, children));
85
+ return context.add({
86
+ id,
87
+ component: 'Card',
88
+ child: link,
89
+ ...(weight === undefined ? {} : { weight }),
90
+ });
91
+ }
92
+ function addProductCard(context, id, reference, product) {
93
+ const image = product.imageUrl?.trim()
94
+ ? context.add({
95
+ id: `${id}_image`,
96
+ component: 'Image',
97
+ url: toWebUrl(product.imageUrl, context.shopUrl, `the image for SKU ${product.sku}`),
98
+ fit: 'cover',
99
+ variant: 'mediumFeature',
100
+ })
101
+ : undefined;
102
+ return addLinkedCard(context, id, product.sku, [
103
+ image,
104
+ addText(context, `${id}_badge`, reference.badge, 'caption'),
105
+ addText(context, `${id}_title`, product.title, 'h5'),
106
+ addText(context, `${id}_price`, context.formatPrice(product), 'h5'),
107
+ addText(context, `${id}_reason`, reference.reason, 'caption'),
108
+ ], 1);
109
+ }
110
+ function addCardRows(context, id, title, items) {
111
+ const cards = [];
112
+ for (const reference of items) {
113
+ const product = sellableProduct(context, reference.sku);
114
+ if (product) {
115
+ cards.push(addProductCard(context, `${id}_item${cards.length}`, reference, product));
116
+ }
117
+ }
118
+ if (cards.length === 0)
119
+ return undefined;
120
+ const rows = [];
121
+ for (let start = 0; start < cards.length; start += CARDS_PER_ROW) {
122
+ const rowId = `${id}_row${rows.length}`;
123
+ const row = cards.slice(start, start + CARDS_PER_ROW);
124
+ while (row.length < CARDS_PER_ROW) {
125
+ row.push(context.add({
126
+ id: `${rowId}_space${row.length}`,
127
+ component: 'Column',
128
+ children: [],
129
+ weight: 1,
130
+ }));
131
+ }
132
+ rows.push(context.add({ id: rowId, component: 'Row', align: 'start', children: row }));
133
+ }
134
+ return addColumn(context, id, [addText(context, `${id}_title`, title, 'h3'), ...rows]);
135
+ }
136
+ function addHero(context, id, block) {
137
+ const product = block.sku === null ? undefined : sellableProduct(context, block.sku);
138
+ const headline = addText(context, `${id}_headline`, block.headline, 'h3');
139
+ const body = addText(context, `${id}_body`, block.body, 'caption');
140
+ if (!headline && !body && !product)
141
+ return undefined;
142
+ const card = product
143
+ ? addLinkedCard(context, `${id}_product`, product.sku, [
144
+ addText(context, `${id}_product_title`, product.title, 'h4'),
145
+ addText(context, `${id}_product_price`, context.formatPrice(product), 'h5'),
146
+ addText(context, `${id}_product_cta`, block.ctaLabel, 'h5'),
147
+ ], undefined)
148
+ : undefined;
149
+ return addStartColumn(context, id, [headline, body, card]);
150
+ }
151
+ function addBanner(context, id, block) {
152
+ const body = addStartColumn(context, `${id}_body`, [
153
+ addText(context, `${id}_text`, block.text, 'h5'),
154
+ addText(context, `${id}_cta`, block.ctaLabel, 'h5'),
155
+ ]);
156
+ return context.add({ id, component: 'Card', child: body });
157
+ }
158
+ function addCopy(context, id, block) {
159
+ return addStartColumn(context, id, [
160
+ addText(context, `${id}_title`, block.title, 'h3'),
161
+ addText(context, `${id}_body`, block.body, 'caption'),
162
+ ]);
163
+ }
164
+ function addBundle(context, id, block) {
165
+ const bundle = block.bundleId === null ? undefined : context.bundles.get(block.bundleId);
166
+ if (!bundle)
167
+ return undefined;
168
+ const products = [];
169
+ for (const sku of bundle.skus) {
170
+ const product = sellableProduct(context, sku);
171
+ if (!product)
172
+ return undefined;
173
+ products.push(product);
174
+ }
175
+ const items = addStartColumn(context, `${id}_items`, products.map((product, index) => addLink(context, `${id}_item${index}`, product.sku, context.add({
176
+ id: `${id}_item${index}_title`,
177
+ component: 'Text',
178
+ text: plainText(product.title) || plainText(product.sku),
179
+ variant: 'h5',
180
+ }))));
181
+ const body = addStartColumn(context, `${id}_body`, [
182
+ addText(context, `${id}_label`, bundle.label ?? null, 'h3'),
183
+ addText(context, `${id}_title`, block.title, 'h4'),
184
+ addText(context, `${id}_text`, block.body, 'caption'),
185
+ items,
186
+ addText(context, `${id}_price`, context.formatBundlePrice(bundle), 'h5'),
187
+ addText(context, `${id}_cta`, block.ctaLabel, 'h5'),
188
+ ]);
189
+ return context.add({ id, component: 'Card', child: body });
190
+ }
191
+ function addBlock(context, id, block) {
192
+ switch (block.kind) {
193
+ case 'hero':
194
+ return addHero(context, id, block);
195
+ case 'grid':
196
+ case 'carousel':
197
+ return addCardRows(context, id, block.title, block.items);
198
+ case 'banner':
199
+ return addBanner(context, id, block);
200
+ case 'copy':
201
+ return addCopy(context, id, block);
202
+ case 'bundle':
203
+ return addBundle(context, id, block);
204
+ default:
205
+ block;
206
+ return undefined;
207
+ }
208
+ }
209
+ export function buildRecommendationMessages(result, options) {
210
+ const shopUrl = toWebUrl(options.shopUrl, undefined, 'the shop URL');
211
+ const hrefForSku = options.hrefForSku ?? defaultHrefForSku;
212
+ const components = [];
213
+ const context = {
214
+ products: new Map(result.products.map((product) => [product.sku, product])),
215
+ bundles: new Map((result.bundles ?? []).map((bundle) => [bundle.id, bundle])),
216
+ shopUrl,
217
+ linkFor: (sku) => toWebUrl(hrefForSku(sku), shopUrl, `the link for SKU ${sku}`),
218
+ formatPrice: options.formatPrice ?? ((product) => defaultFormatPrice(product, options.locale)),
219
+ formatBundlePrice: options.formatBundlePrice ?? ((bundle) => defaultFormatBundlePrice(bundle, options.locale)),
220
+ add: (component) => {
221
+ components.push(component);
222
+ return component.id;
223
+ },
224
+ };
225
+ const blocks = defined(result.spec.blocks.map((block, index) => addBlock(context, `block${index}`, block)));
226
+ if (blocks.length === 0)
227
+ return [];
228
+ addColumn(context, 'root', [
229
+ addText(context, 'headline', result.spec.headline, 'h2'),
230
+ addText(context, 'subheadline', result.spec.subheadline, 'caption'),
231
+ ...blocks,
232
+ ]);
233
+ const { surfaceId } = options;
234
+ return [
235
+ {
236
+ version: 'v0.9',
237
+ createSurface: { surfaceId, catalogId: options.catalogId ?? BASIC_CATALOG_ID },
238
+ },
239
+ { version: 'v0.9', updateComponents: { surfaceId, components } },
240
+ ];
241
+ }
242
+ //# sourceMappingURL=recommendation-messages.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recommendation-messages.js","sourceRoot":"","sources":["../src/recommendation-messages.ts"],"names":[],"mappings":"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"}
package/dist/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { RUDRA_RECOMMENDATIONS_INSTRUCTIONS, rudraRecommendationsApi, } from './recommendations-api.js';
2
2
  export { fitRecommendationRequest, parseRecommendationRequest, type RecommendationRequest, } from './recommendation-request.js';
3
+ export { buildRecommendationMessages, type RecommendationMessagesOptions, } from './recommendation-messages.js';
3
4
  export type { RecommendationResult } from './recommendation-result.js';
4
5
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kCAAkC,EAClC,uBAAuB,GACxB,MAAM,0BAA0B,CAAC;AAElC,OAAO,EACL,wBAAwB,EACxB,0BAA0B,EAC1B,KAAK,qBAAqB,GAC3B,MAAM,6BAA6B,CAAC;AAErC,YAAY,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kCAAkC,EAClC,uBAAuB,GACxB,MAAM,0BAA0B,CAAC;AAElC,OAAO,EACL,wBAAwB,EACxB,0BAA0B,EAC1B,KAAK,qBAAqB,GAC3B,MAAM,6BAA6B,CAAC;AAErC,OAAO,EACL,2BAA2B,EAC3B,KAAK,6BAA6B,GACnC,MAAM,8BAA8B,CAAC;AAEtC,YAAY,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC"}
package/dist/server.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { RUDRA_RECOMMENDATIONS_INSTRUCTIONS, rudraRecommendationsApi, } from './recommendations-api.js';
2
2
  export { fitRecommendationRequest, parseRecommendationRequest, } from './recommendation-request.js';
3
+ export { buildRecommendationMessages, } from './recommendation-messages.js';
3
4
  //# sourceMappingURL=server.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kCAAkC,EAClC,uBAAuB,GACxB,MAAM,0BAA0B,CAAC;AAElC,OAAO,EACL,wBAAwB,EACxB,0BAA0B,GAE3B,MAAM,6BAA6B,CAAC"}
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kCAAkC,EAClC,uBAAuB,GACxB,MAAM,0BAA0B,CAAC;AAElC,OAAO,EACL,wBAAwB,EACxB,0BAA0B,GAE3B,MAAM,6BAA6B,CAAC;AAErC,OAAO,EACL,2BAA2B,GAE5B,MAAM,8BAA8B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rudra-js/a2ui",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "A2UI component for rudra-js: lets an A2UI agent place a product-recommendation block whose products, prices and wording come from your shop, not from the agent",
5
5
  "keywords": [
6
6
  "rudra-js",
@@ -62,19 +62,30 @@
62
62
  "peerDependencies": {
63
63
  "@a2ui/react": "^0.14.0",
64
64
  "@a2ui/web_core": "^0.14.0",
65
- "@rudra-js/core": "^0.8.2",
66
- "@rudra-js/react": "^0.8.2",
65
+ "@rudra-js/core": "^0.9.0",
66
+ "@rudra-js/react": "^0.9.0",
67
67
  "react": "^18.2.0 || ^19.0.0"
68
68
  },
69
+ "peerDependenciesMeta": {
70
+ "@a2ui/react": {
71
+ "optional": true
72
+ },
73
+ "@rudra-js/react": {
74
+ "optional": true
75
+ },
76
+ "react": {
77
+ "optional": true
78
+ }
79
+ },
69
80
  "devDependencies": {
70
81
  "@a2ui/react": "0.14.0",
71
82
  "@a2ui/web_core": "0.14.0",
72
- "@rudra-js/core": "0.8.2",
73
- "@rudra-js/react": "0.8.2",
83
+ "@rudra-js/core": "0.9.0",
84
+ "@rudra-js/react": "0.9.0",
74
85
  "@types/react": "^19.3.0",
75
86
  "@types/react-dom": "^19.3.0",
76
87
  "jsdom": "^27.4.0",
77
- "react": "^19.3.0",
78
- "react-dom": "^19.3.0"
88
+ "react": "^19.3.1",
89
+ "react-dom": "^19.3.1"
79
90
  }
80
91
  }
@@ -0,0 +1,372 @@
1
+ import type { A2uiMessage } from '@a2ui/web_core/v0_9';
2
+ import type {
3
+ BannerBlock,
4
+ Block,
5
+ Bundle,
6
+ BundleBlock,
7
+ CopyBlock,
8
+ HeroBlock,
9
+ Product,
10
+ ProductReference,
11
+ } from '@rudra-js/core';
12
+ import type { RecommendationResult } from './recommendation-result.js';
13
+
14
+ export interface RecommendationMessagesOptions {
15
+ surfaceId: string;
16
+ shopUrl: string;
17
+ catalogId?: string | undefined;
18
+ hrefForSku?: ((sku: string) => string) | undefined;
19
+ formatPrice?: ((product: Product) => string) | undefined;
20
+ formatBundlePrice?: ((bundle: Bundle) => string) | undefined;
21
+ locale?: string | undefined;
22
+ }
23
+
24
+ const BASIC_CATALOG_ID = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json';
25
+ const CARDS_PER_ROW = 2;
26
+
27
+ type TextVariant = 'h2' | 'h3' | 'h4' | 'h5' | 'caption';
28
+
29
+ interface OpenUrlAction {
30
+ functionCall: { call: 'openUrl'; args: { url: string }; returnType: 'void' };
31
+ }
32
+
33
+ type BasicComponent =
34
+ | { id: string; component: 'Text'; text: string; variant: TextVariant }
35
+ | { id: string; component: 'Image'; url: string; fit: 'cover'; variant: 'mediumFeature' }
36
+ | { id: string; component: 'Row'; children: string[]; align: 'start' }
37
+ | { id: string; component: 'Column'; children: string[]; align?: 'start'; weight?: number }
38
+ | { id: string; component: 'Card'; child: string; weight?: number }
39
+ | {
40
+ id: string;
41
+ component: 'Button';
42
+ child: string;
43
+ variant: 'borderless';
44
+ action: OpenUrlAction;
45
+ };
46
+
47
+ interface BuildContext {
48
+ readonly products: ReadonlyMap<string, Product>;
49
+ readonly bundles: ReadonlyMap<string, Bundle>;
50
+ readonly shopUrl: string;
51
+ readonly linkFor: (sku: string) => string;
52
+ readonly formatPrice: (product: Product) => string;
53
+ readonly formatBundlePrice: (bundle: Bundle) => string;
54
+ readonly add: (component: BasicComponent) => string;
55
+ }
56
+
57
+ function defaultHrefForSku(sku: string): string {
58
+ return `/product/${encodeURIComponent(sku)}`;
59
+ }
60
+
61
+ function formatMoney(price: number, currency: string, locale: string | undefined): string {
62
+ let formatter: Intl.NumberFormat;
63
+ try {
64
+ formatter = new Intl.NumberFormat(locale, { style: 'currency', currency });
65
+ } catch {
66
+ return `${currency} ${price}`;
67
+ }
68
+ return formatter.format(price);
69
+ }
70
+
71
+ function defaultFormatPrice(product: Product, locale: string | undefined): string {
72
+ if (!Number.isFinite(product.price)) {
73
+ throw new TypeError(
74
+ `price for SKU ${product.sku} is ${String(product.price)}, not a finite number. ` +
75
+ 'Catalog objects must satisfy productSchema from @rudra-js/core',
76
+ );
77
+ }
78
+ return formatMoney(product.price, product.currency, locale);
79
+ }
80
+
81
+ function defaultFormatBundlePrice(bundle: Bundle, locale: string | undefined): string {
82
+ if (!Number.isFinite(bundle.price)) {
83
+ throw new TypeError(
84
+ `price for bundle ${bundle.id} is ${String(bundle.price)}, not a finite number. ` +
85
+ 'Bundle objects must satisfy bundleSchema from @rudra-js/core',
86
+ );
87
+ }
88
+ return formatMoney(bundle.price, bundle.currency, locale);
89
+ }
90
+
91
+ function toWebUrl(value: unknown, base: string | undefined, name: string): string {
92
+ if (typeof value !== 'string') throw new TypeError(`${name} is not a string`);
93
+ if (value.trim() === '') throw new TypeError(`${name} is empty`);
94
+ let url: URL;
95
+ try {
96
+ url = new URL(value, base);
97
+ } catch {
98
+ throw new TypeError(`${name} is not a URL: ${value}`);
99
+ }
100
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
101
+ throw new TypeError(`${name} is not an http or https URL: ${value}`);
102
+ }
103
+ return url.href;
104
+ }
105
+
106
+ function plainText(value: string): string {
107
+ return value
108
+ .replace(/[\t\n\v\f\r \u0085\u2028\u2029]+/g, ' ')
109
+ .trim()
110
+ .replaceAll('[', '(')
111
+ .replaceAll(']', ')')
112
+ .replaceAll('<', '‹')
113
+ .replaceAll('>', '›');
114
+ }
115
+
116
+ function defined(ids: readonly (string | undefined)[]): string[] {
117
+ return ids.filter((id): id is string => id !== undefined);
118
+ }
119
+
120
+ function sellableProduct(context: BuildContext, sku: string): Product | undefined {
121
+ const product = context.products.get(sku);
122
+ return product?.isInStock === false ? undefined : product;
123
+ }
124
+
125
+ function addText(
126
+ context: BuildContext,
127
+ id: string,
128
+ value: string | null,
129
+ variant: TextVariant,
130
+ ): string | undefined {
131
+ const text = value === null ? '' : plainText(value);
132
+ return text ? context.add({ id, component: 'Text', text, variant }) : undefined;
133
+ }
134
+
135
+ function addStartColumn(
136
+ context: BuildContext,
137
+ id: string,
138
+ children: readonly (string | undefined)[],
139
+ ) {
140
+ return context.add({ id, component: 'Column', align: 'start', children: defined(children) });
141
+ }
142
+
143
+ function addColumn(context: BuildContext, id: string, children: readonly (string | undefined)[]) {
144
+ return context.add({ id, component: 'Column', children: defined(children) });
145
+ }
146
+
147
+ function addLink(context: BuildContext, id: string, sku: string, child: string): string {
148
+ const url = context.linkFor(sku);
149
+ return context.add({
150
+ id,
151
+ component: 'Button',
152
+ child,
153
+ variant: 'borderless',
154
+ action: { functionCall: { call: 'openUrl', args: { url }, returnType: 'void' } },
155
+ });
156
+ }
157
+
158
+ function addLinkedCard(
159
+ context: BuildContext,
160
+ id: string,
161
+ sku: string,
162
+ children: readonly (string | undefined)[],
163
+ weight: number | undefined,
164
+ ): string {
165
+ const link = addLink(context, `${id}_link`, sku, addStartColumn(context, `${id}_body`, children));
166
+ return context.add({
167
+ id,
168
+ component: 'Card',
169
+ child: link,
170
+ ...(weight === undefined ? {} : { weight }),
171
+ });
172
+ }
173
+
174
+ function addProductCard(
175
+ context: BuildContext,
176
+ id: string,
177
+ reference: ProductReference,
178
+ product: Product,
179
+ ): string {
180
+ const image = product.imageUrl?.trim()
181
+ ? context.add({
182
+ id: `${id}_image`,
183
+ component: 'Image',
184
+ url: toWebUrl(product.imageUrl, context.shopUrl, `the image for SKU ${product.sku}`),
185
+ fit: 'cover',
186
+ variant: 'mediumFeature',
187
+ })
188
+ : undefined;
189
+ return addLinkedCard(
190
+ context,
191
+ id,
192
+ product.sku,
193
+ [
194
+ image,
195
+ addText(context, `${id}_badge`, reference.badge, 'caption'),
196
+ addText(context, `${id}_title`, product.title, 'h5'),
197
+ addText(context, `${id}_price`, context.formatPrice(product), 'h5'),
198
+ addText(context, `${id}_reason`, reference.reason, 'caption'),
199
+ ],
200
+ 1,
201
+ );
202
+ }
203
+
204
+ function addCardRows(
205
+ context: BuildContext,
206
+ id: string,
207
+ title: string | null,
208
+ items: readonly ProductReference[],
209
+ ): string | undefined {
210
+ const cards: string[] = [];
211
+ for (const reference of items) {
212
+ const product = sellableProduct(context, reference.sku);
213
+ if (product) {
214
+ cards.push(addProductCard(context, `${id}_item${cards.length}`, reference, product));
215
+ }
216
+ }
217
+ if (cards.length === 0) return undefined;
218
+
219
+ const rows: string[] = [];
220
+ for (let start = 0; start < cards.length; start += CARDS_PER_ROW) {
221
+ const rowId = `${id}_row${rows.length}`;
222
+ const row = cards.slice(start, start + CARDS_PER_ROW);
223
+ while (row.length < CARDS_PER_ROW) {
224
+ row.push(
225
+ context.add({
226
+ id: `${rowId}_space${row.length}`,
227
+ component: 'Column',
228
+ children: [],
229
+ weight: 1,
230
+ }),
231
+ );
232
+ }
233
+ rows.push(context.add({ id: rowId, component: 'Row', align: 'start', children: row }));
234
+ }
235
+ return addColumn(context, id, [addText(context, `${id}_title`, title, 'h3'), ...rows]);
236
+ }
237
+
238
+ function addHero(context: BuildContext, id: string, block: HeroBlock): string | undefined {
239
+ const product = block.sku === null ? undefined : sellableProduct(context, block.sku);
240
+ const headline = addText(context, `${id}_headline`, block.headline, 'h3');
241
+ const body = addText(context, `${id}_body`, block.body, 'caption');
242
+ if (!headline && !body && !product) return undefined;
243
+
244
+ const card = product
245
+ ? addLinkedCard(
246
+ context,
247
+ `${id}_product`,
248
+ product.sku,
249
+ [
250
+ addText(context, `${id}_product_title`, product.title, 'h4'),
251
+ addText(context, `${id}_product_price`, context.formatPrice(product), 'h5'),
252
+ addText(context, `${id}_product_cta`, block.ctaLabel, 'h5'),
253
+ ],
254
+ undefined,
255
+ )
256
+ : undefined;
257
+ return addStartColumn(context, id, [headline, body, card]);
258
+ }
259
+
260
+ function addBanner(context: BuildContext, id: string, block: BannerBlock): string {
261
+ const body = addStartColumn(context, `${id}_body`, [
262
+ addText(context, `${id}_text`, block.text, 'h5'),
263
+ addText(context, `${id}_cta`, block.ctaLabel, 'h5'),
264
+ ]);
265
+ return context.add({ id, component: 'Card', child: body });
266
+ }
267
+
268
+ function addCopy(context: BuildContext, id: string, block: CopyBlock): string {
269
+ return addStartColumn(context, id, [
270
+ addText(context, `${id}_title`, block.title, 'h3'),
271
+ addText(context, `${id}_body`, block.body, 'caption'),
272
+ ]);
273
+ }
274
+
275
+ function addBundle(context: BuildContext, id: string, block: BundleBlock): string | undefined {
276
+ const bundle = block.bundleId === null ? undefined : context.bundles.get(block.bundleId);
277
+ if (!bundle) return undefined;
278
+
279
+ const products: Product[] = [];
280
+ for (const sku of bundle.skus) {
281
+ const product = sellableProduct(context, sku);
282
+ if (!product) return undefined;
283
+ products.push(product);
284
+ }
285
+
286
+ const items = addStartColumn(
287
+ context,
288
+ `${id}_items`,
289
+ products.map((product, index) =>
290
+ addLink(
291
+ context,
292
+ `${id}_item${index}`,
293
+ product.sku,
294
+ context.add({
295
+ id: `${id}_item${index}_title`,
296
+ component: 'Text',
297
+ text: plainText(product.title) || plainText(product.sku),
298
+ variant: 'h5',
299
+ }),
300
+ ),
301
+ ),
302
+ );
303
+ const body = addStartColumn(context, `${id}_body`, [
304
+ addText(context, `${id}_label`, bundle.label ?? null, 'h3'),
305
+ addText(context, `${id}_title`, block.title, 'h4'),
306
+ addText(context, `${id}_text`, block.body, 'caption'),
307
+ items,
308
+ addText(context, `${id}_price`, context.formatBundlePrice(bundle), 'h5'),
309
+ addText(context, `${id}_cta`, block.ctaLabel, 'h5'),
310
+ ]);
311
+ return context.add({ id, component: 'Card', child: body });
312
+ }
313
+
314
+ function addBlock(context: BuildContext, id: string, block: Block): string | undefined {
315
+ switch (block.kind) {
316
+ case 'hero':
317
+ return addHero(context, id, block);
318
+ case 'grid':
319
+ case 'carousel':
320
+ return addCardRows(context, id, block.title, block.items);
321
+ case 'banner':
322
+ return addBanner(context, id, block);
323
+ case 'copy':
324
+ return addCopy(context, id, block);
325
+ case 'bundle':
326
+ return addBundle(context, id, block);
327
+ default:
328
+ block satisfies never;
329
+ return undefined;
330
+ }
331
+ }
332
+
333
+ export function buildRecommendationMessages(
334
+ result: RecommendationResult,
335
+ options: RecommendationMessagesOptions,
336
+ ): A2uiMessage[] {
337
+ const shopUrl = toWebUrl(options.shopUrl, undefined, 'the shop URL');
338
+ const hrefForSku = options.hrefForSku ?? defaultHrefForSku;
339
+ const components: BasicComponent[] = [];
340
+ const context: BuildContext = {
341
+ products: new Map(result.products.map((product) => [product.sku, product])),
342
+ bundles: new Map((result.bundles ?? []).map((bundle) => [bundle.id, bundle])),
343
+ shopUrl,
344
+ linkFor: (sku) => toWebUrl(hrefForSku(sku), shopUrl, `the link for SKU ${sku}`),
345
+ formatPrice: options.formatPrice ?? ((product) => defaultFormatPrice(product, options.locale)),
346
+ formatBundlePrice:
347
+ options.formatBundlePrice ?? ((bundle) => defaultFormatBundlePrice(bundle, options.locale)),
348
+ add: (component) => {
349
+ components.push(component);
350
+ return component.id;
351
+ },
352
+ };
353
+
354
+ const blocks = defined(
355
+ result.spec.blocks.map((block, index) => addBlock(context, `block${index}`, block)),
356
+ );
357
+ if (blocks.length === 0) return [];
358
+
359
+ addColumn(context, 'root', [
360
+ addText(context, 'headline', result.spec.headline, 'h2'),
361
+ addText(context, 'subheadline', result.spec.subheadline, 'caption'),
362
+ ...blocks,
363
+ ]);
364
+ const { surfaceId } = options;
365
+ return [
366
+ {
367
+ version: 'v0.9',
368
+ createSurface: { surfaceId, catalogId: options.catalogId ?? BASIC_CATALOG_ID },
369
+ },
370
+ { version: 'v0.9', updateComponents: { surfaceId, components } },
371
+ ];
372
+ }
package/src/server.ts CHANGED
@@ -9,4 +9,9 @@ export {
9
9
  type RecommendationRequest,
10
10
  } from './recommendation-request.js';
11
11
 
12
+ export {
13
+ buildRecommendationMessages,
14
+ type RecommendationMessagesOptions,
15
+ } from './recommendation-messages.js';
16
+
12
17
  export type { RecommendationResult } from './recommendation-result.js';