@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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +183 -0
  3. package/dist/blocks/block-renderers.d.ts +30 -0
  4. package/dist/blocks/block-renderers.d.ts.map +1 -0
  5. package/dist/blocks/block-renderers.js +19 -0
  6. package/dist/blocks/block-renderers.js.map +1 -0
  7. package/dist/blocks/bundle-block.d.ts +7 -0
  8. package/dist/blocks/bundle-block.d.ts.map +1 -0
  9. package/dist/blocks/bundle-block.js +16 -0
  10. package/dist/blocks/bundle-block.js.map +1 -0
  11. package/dist/blocks/product-card.d.ts +15 -0
  12. package/dist/blocks/product-card.d.ts.map +1 -0
  13. package/dist/blocks/product-card.js +19 -0
  14. package/dist/blocks/product-card.js.map +1 -0
  15. package/dist/index.d.ts +11 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +14 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/registry.d.ts +33 -0
  20. package/dist/registry.d.ts.map +1 -0
  21. package/dist/registry.js +39 -0
  22. package/dist/registry.js.map +1 -0
  23. package/dist/render-context.d.ts +46 -0
  24. package/dist/render-context.d.ts.map +1 -0
  25. package/dist/render-context.js +68 -0
  26. package/dist/render-context.js.map +1 -0
  27. package/dist/rudra-component.d.ts +60 -0
  28. package/dist/rudra-component.d.ts.map +1 -0
  29. package/dist/rudra-component.js +132 -0
  30. package/dist/rudra-component.js.map +1 -0
  31. package/package.json +64 -0
  32. package/src/blocks/block-renderers.tsx +102 -0
  33. package/src/blocks/bundle-block.tsx +43 -0
  34. package/src/blocks/product-card.tsx +45 -0
  35. package/src/index.ts +31 -0
  36. package/src/registry.ts +59 -0
  37. package/src/render-context.ts +99 -0
  38. 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
+ }