@rudra-js/react 0.1.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/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/blocks/block-renderers.d.ts +30 -0
- package/dist/blocks/block-renderers.d.ts.map +1 -0
- package/dist/blocks/block-renderers.js +19 -0
- package/dist/blocks/block-renderers.js.map +1 -0
- package/dist/blocks/bundle-block.d.ts +7 -0
- package/dist/blocks/bundle-block.d.ts.map +1 -0
- package/dist/blocks/bundle-block.js +16 -0
- package/dist/blocks/bundle-block.js.map +1 -0
- package/dist/blocks/product-card.d.ts +15 -0
- package/dist/blocks/product-card.d.ts.map +1 -0
- package/dist/blocks/product-card.js +19 -0
- package/dist/blocks/product-card.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +33 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +39 -0
- package/dist/registry.js.map +1 -0
- package/dist/render-context.d.ts +46 -0
- package/dist/render-context.d.ts.map +1 -0
- package/dist/render-context.js +68 -0
- package/dist/render-context.js.map +1 -0
- package/dist/rudra-component.d.ts +60 -0
- package/dist/rudra-component.d.ts.map +1 -0
- package/dist/rudra-component.js +132 -0
- package/dist/rudra-component.js.map +1 -0
- package/package.json +64 -0
- package/src/blocks/block-renderers.tsx +102 -0
- package/src/blocks/bundle-block.tsx +43 -0
- package/src/blocks/product-card.tsx +45 -0
- package/src/index.ts +31 -0
- package/src/registry.ts +59 -0
- package/src/render-context.ts +99 -0
- package/src/rudra-component.tsx +234 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { Block, Bundle, ComponentSpec, Product } from '@rudra-js/core';
|
|
2
|
+
import {
|
|
3
|
+
defaultFormatBundlePrice,
|
|
4
|
+
defaultFormatPrice,
|
|
5
|
+
defaultHrefForSku,
|
|
6
|
+
type BlockRenderContext,
|
|
7
|
+
} from './render-context.js';
|
|
8
|
+
import { defaultRegistry, type BlockRegistry } from './registry.js';
|
|
9
|
+
|
|
10
|
+
export interface RudraComponentProps {
|
|
11
|
+
spec: ComponentSpec;
|
|
12
|
+
/**
|
|
13
|
+
* The host catalog. Every product fact on the page comes from here rather
|
|
14
|
+
* than from the specification.
|
|
15
|
+
*
|
|
16
|
+
* A list of products, or anything keyed by SKU — a `Map`, or your own view
|
|
17
|
+
* over a catalog too large to hold in one. The renderers only ever call
|
|
18
|
+
* `get(sku)` and `has(sku)`, so a view needs nothing else to be fast.
|
|
19
|
+
*
|
|
20
|
+
* Validate them with `productSchema` from `@rudra-js/core` — the same schema
|
|
21
|
+
* your candidates already passed — not with `parseTrackingInput`, which
|
|
22
|
+
* parses a whole tracking payload and will reject a bare catalog.
|
|
23
|
+
*
|
|
24
|
+
* This is a second door into the framework. `imageUrl` lands in an
|
|
25
|
+
* `<img src>`, and `productSchema` is the only thing that rejects a
|
|
26
|
+
* protocol-relative `//evil.example/pixel.png` or a `data:` URL — React
|
|
27
|
+
* neutralises `javascript:` on its own, but not those. A price that is not a
|
|
28
|
+
* finite number throws rather than rendering as free.
|
|
29
|
+
*/
|
|
30
|
+
products: ProductCatalog;
|
|
31
|
+
/** Sets the shop sells together. Only needed if a spec can carry a bundle block. */
|
|
32
|
+
bundles?: readonly Bundle[];
|
|
33
|
+
registry?: BlockRegistry;
|
|
34
|
+
hrefForSku?: (sku: string) => string;
|
|
35
|
+
formatPrice?: (product: Product) => string;
|
|
36
|
+
/** Same as `formatPrice`, but for a bundle — the shop's price, not a sum of the parts. */
|
|
37
|
+
formatBundlePrice?: (bundle: Bundle) => string;
|
|
38
|
+
/**
|
|
39
|
+
* The shopper's locale, used to punctuate prices. Defaults to the server's,
|
|
40
|
+
* which is almost never the shopper's — pass it if the shop serves more than
|
|
41
|
+
* one. Ignored when `formatPrice` and `formatBundlePrice` are supplied.
|
|
42
|
+
*/
|
|
43
|
+
locale?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Adds the model's own reasoning, the provider and the model name to the
|
|
46
|
+
* markup. Useful while developing and while benchmarking; it publishes which
|
|
47
|
+
* vendor a shop uses and whether the component is currently degraded, so it
|
|
48
|
+
* is off unless asked for.
|
|
49
|
+
*/
|
|
50
|
+
hasDiagnostics?: boolean;
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A list of products, or anything keyed by SKU that answers `get` and `has`. */
|
|
55
|
+
export type ProductCatalog = readonly Product[] | ReadonlyMap<string, Product>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the catalog is already keyed by SKU.
|
|
59
|
+
*
|
|
60
|
+
* Asks what the renderers actually call rather than which class the host
|
|
61
|
+
* happened to construct. `instanceof Map` was wrong twice over: a Map that
|
|
62
|
+
* crossed a realm boundary — a `node:vm` context, a worker — fails it, and so
|
|
63
|
+
* does a host's own `ReadonlyMap`, which the prop type has always allowed. Both
|
|
64
|
+
* then fell into the list branch, where `catalog.map is not a function` throws
|
|
65
|
+
* while the render context is being built, before any block renders. That takes
|
|
66
|
+
* down the whole page, not just this component.
|
|
67
|
+
*
|
|
68
|
+
* Keyed before list, because a collection can answer both: an Immutable.js map
|
|
69
|
+
* has `map`, and converting through it yields a catalog whose every value is a
|
|
70
|
+
* `[sku, product]` pair rather than a product.
|
|
71
|
+
*/
|
|
72
|
+
function isKeyedBySku(catalog: ProductCatalog): catalog is ReadonlyMap<string, Product> {
|
|
73
|
+
const candidate = catalog as { get?: unknown; has?: unknown };
|
|
74
|
+
return typeof candidate.get === 'function' && typeof candidate.has === 'function';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toProductMap(catalog: ProductCatalog): ReadonlyMap<string, Product> {
|
|
78
|
+
if (isKeyedBySku(catalog)) return catalog;
|
|
79
|
+
if (typeof (catalog as { map?: unknown }).map !== 'function') {
|
|
80
|
+
// A Set of products, a plain object keyed by SKU, a Map that has been
|
|
81
|
+
// through JSON. Refusing here names the prop while the stack still points
|
|
82
|
+
// at it. Carried through instead, a grid renders nothing and a banner
|
|
83
|
+
// renders a healthy-looking page, and the shop finds out from a dashboard.
|
|
84
|
+
throw new TypeError(
|
|
85
|
+
'the `products` prop must be a list of products, or keyed by SKU with `get` and `has` — ' +
|
|
86
|
+
`received ${Object.prototype.toString.call(catalog)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return new Map(catalog.map((product) => [product.sku, product]));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a block still has anything to say once the catalog is applied.
|
|
94
|
+
*
|
|
95
|
+
* Three block kinds can come up empty: reconciliation ran against the catalog
|
|
96
|
+
* as it was when the spec was generated, and a SKU can sell out between then
|
|
97
|
+
* and this render. Grid and carousel lose just the products that did; a
|
|
98
|
+
* bundle loses itself entirely if any one of its members did. The rest carry
|
|
99
|
+
* their own words.
|
|
100
|
+
*/
|
|
101
|
+
function hasContent(
|
|
102
|
+
block: Block,
|
|
103
|
+
products: ReadonlyMap<string, Product>,
|
|
104
|
+
bundles: ReadonlyMap<string, Bundle>,
|
|
105
|
+
): boolean {
|
|
106
|
+
switch (block.kind) {
|
|
107
|
+
case 'grid':
|
|
108
|
+
case 'carousel':
|
|
109
|
+
return block.items.some((reference) => products.has(reference.sku));
|
|
110
|
+
case 'hero':
|
|
111
|
+
case 'banner':
|
|
112
|
+
case 'copy':
|
|
113
|
+
return true;
|
|
114
|
+
case 'bundle': {
|
|
115
|
+
if (block.bundleId === null) return false;
|
|
116
|
+
const bundle = bundles.get(block.bundleId);
|
|
117
|
+
return bundle !== undefined && bundle.skus.every((sku) => products.has(sku));
|
|
118
|
+
}
|
|
119
|
+
default:
|
|
120
|
+
// A kind this renderer predates renders nothing, so it counts as nothing.
|
|
121
|
+
block satisfies never;
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderBlock(
|
|
127
|
+
block: Block,
|
|
128
|
+
context: BlockRenderContext,
|
|
129
|
+
registry: BlockRegistry,
|
|
130
|
+
index: number,
|
|
131
|
+
) {
|
|
132
|
+
switch (block.kind) {
|
|
133
|
+
case 'hero':
|
|
134
|
+
return <registry.hero key={index} block={block} context={context} />;
|
|
135
|
+
case 'grid':
|
|
136
|
+
return <registry.grid key={index} block={block} context={context} />;
|
|
137
|
+
case 'carousel':
|
|
138
|
+
return <registry.carousel key={index} block={block} context={context} />;
|
|
139
|
+
case 'banner':
|
|
140
|
+
return <registry.banner key={index} block={block} context={context} />;
|
|
141
|
+
case 'copy':
|
|
142
|
+
return <registry.copy key={index} block={block} context={context} />;
|
|
143
|
+
case 'bundle':
|
|
144
|
+
return <registry.bundle key={index} block={block} context={context} />;
|
|
145
|
+
default:
|
|
146
|
+
// A newer core carrying a block kind this renderer predates loses that
|
|
147
|
+
// block rather than the page. In this repo the assertion below fails the
|
|
148
|
+
// build instead, which is the moment it is cheap to notice.
|
|
149
|
+
block satisfies never;
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Renders a component specification.
|
|
156
|
+
*
|
|
157
|
+
* A Server Component: no hooks, no state, no effects, and therefore no client
|
|
158
|
+
* bundle and no hydration for the recommendation area. The whole component
|
|
159
|
+
* arrives in the initial HTML response, which is what removes the pop-in a
|
|
160
|
+
* client-fetched recommendation rail has — and what makes the content visible
|
|
161
|
+
* to a crawler that does not run JavaScript.
|
|
162
|
+
*
|
|
163
|
+
* Renders nothing at all when no block produced markup.
|
|
164
|
+
*/
|
|
165
|
+
export function RudraComponent({
|
|
166
|
+
spec,
|
|
167
|
+
products,
|
|
168
|
+
bundles,
|
|
169
|
+
registry = defaultRegistry,
|
|
170
|
+
hrefForSku = defaultHrefForSku,
|
|
171
|
+
formatPrice,
|
|
172
|
+
formatBundlePrice,
|
|
173
|
+
locale,
|
|
174
|
+
hasDiagnostics = false,
|
|
175
|
+
className,
|
|
176
|
+
}: RudraComponentProps) {
|
|
177
|
+
const productMap = toProductMap(products);
|
|
178
|
+
const bundlesById = new Map((bundles ?? []).map((bundle) => [bundle.id, bundle]));
|
|
179
|
+
|
|
180
|
+
const context: BlockRenderContext = {
|
|
181
|
+
products: productMap,
|
|
182
|
+
bundles: bundlesById,
|
|
183
|
+
hrefForSku,
|
|
184
|
+
formatPrice: formatPrice ?? ((product) => defaultFormatPrice(product, locale)),
|
|
185
|
+
formatBundlePrice: formatBundlePrice ?? ((bundle) => defaultFormatBundlePrice(bundle, locale)),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// An empty recommendation area is worse than none: it takes up space and
|
|
189
|
+
// tells the shopper the page is broken. That includes the subtler version —
|
|
190
|
+
// a headline and an empty box, because every product in the spec has sold out
|
|
191
|
+
// since it was generated — which is why this asks what is left rather than
|
|
192
|
+
// how many blocks arrived.
|
|
193
|
+
const visible = spec.blocks.filter((block) =>
|
|
194
|
+
hasContent(block, context.products, context.bundles),
|
|
195
|
+
);
|
|
196
|
+
if (visible.length === 0) return null;
|
|
197
|
+
|
|
198
|
+
// React omits a data-* attribute whose value is undefined, so degradedReason
|
|
199
|
+
// needs no branch of its own.
|
|
200
|
+
const diagnosticAttributes = hasDiagnostics
|
|
201
|
+
? {
|
|
202
|
+
'data-rudra-provider': spec.provider ?? 'none',
|
|
203
|
+
'data-rudra-model': spec.model ?? 'none',
|
|
204
|
+
'data-rudra-latency-ms': String(spec.latencyMs),
|
|
205
|
+
'data-rudra-degraded': spec.degradedReason,
|
|
206
|
+
}
|
|
207
|
+
: undefined;
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<section
|
|
211
|
+
// Extended rather than replaced: every child class is namespaced under
|
|
212
|
+
// `rudra`, and the package ships no stylesheet, so a host will pass one.
|
|
213
|
+
className={className ? `rudra ${className}` : 'rudra'}
|
|
214
|
+
data-rudra-slot={spec.slot}
|
|
215
|
+
// Where the component came from travels with the markup on purpose, so
|
|
216
|
+
// hit rate and fallback share can be read off a rendered page — which
|
|
217
|
+
// means `source="fallback"` is public. What stays behind the diagnostics
|
|
218
|
+
// flag is everything more specific than that: which vendor, which model,
|
|
219
|
+
// how slow, and why it fell back.
|
|
220
|
+
data-rudra-source={spec.source}
|
|
221
|
+
data-rudra-tone={spec.tone}
|
|
222
|
+
{...diagnosticAttributes}
|
|
223
|
+
>
|
|
224
|
+
<header className="rudra__header">
|
|
225
|
+
<h2 className="rudra__headline">{spec.headline}</h2>
|
|
226
|
+
{spec.subheadline ? <p className="rudra__subheadline">{spec.subheadline}</p> : null}
|
|
227
|
+
</header>
|
|
228
|
+
|
|
229
|
+
{visible.map((block, index) => renderBlock(block, context, registry, index))}
|
|
230
|
+
|
|
231
|
+
{hasDiagnostics ? <p className="rudra__rationale">{spec.rationale}</p> : null}
|
|
232
|
+
</section>
|
|
233
|
+
);
|
|
234
|
+
}
|