@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,60 @@
|
|
|
1
|
+
import type { Bundle, ComponentSpec, Product } from '@rudra-js/core';
|
|
2
|
+
import { type BlockRegistry } from './registry.js';
|
|
3
|
+
export interface RudraComponentProps {
|
|
4
|
+
spec: ComponentSpec;
|
|
5
|
+
/**
|
|
6
|
+
* The host catalog. Every product fact on the page comes from here rather
|
|
7
|
+
* than from the specification.
|
|
8
|
+
*
|
|
9
|
+
* A list of products, or anything keyed by SKU — a `Map`, or your own view
|
|
10
|
+
* over a catalog too large to hold in one. The renderers only ever call
|
|
11
|
+
* `get(sku)` and `has(sku)`, so a view needs nothing else to be fast.
|
|
12
|
+
*
|
|
13
|
+
* Validate them with `productSchema` from `@rudra-js/core` — the same schema
|
|
14
|
+
* your candidates already passed — not with `parseTrackingInput`, which
|
|
15
|
+
* parses a whole tracking payload and will reject a bare catalog.
|
|
16
|
+
*
|
|
17
|
+
* This is a second door into the framework. `imageUrl` lands in an
|
|
18
|
+
* `<img src>`, and `productSchema` is the only thing that rejects a
|
|
19
|
+
* protocol-relative `//evil.example/pixel.png` or a `data:` URL — React
|
|
20
|
+
* neutralises `javascript:` on its own, but not those. A price that is not a
|
|
21
|
+
* finite number throws rather than rendering as free.
|
|
22
|
+
*/
|
|
23
|
+
products: ProductCatalog;
|
|
24
|
+
/** Sets the shop sells together. Only needed if a spec can carry a bundle block. */
|
|
25
|
+
bundles?: readonly Bundle[];
|
|
26
|
+
registry?: BlockRegistry;
|
|
27
|
+
hrefForSku?: (sku: string) => string;
|
|
28
|
+
formatPrice?: (product: Product) => string;
|
|
29
|
+
/** Same as `formatPrice`, but for a bundle — the shop's price, not a sum of the parts. */
|
|
30
|
+
formatBundlePrice?: (bundle: Bundle) => string;
|
|
31
|
+
/**
|
|
32
|
+
* The shopper's locale, used to punctuate prices. Defaults to the server's,
|
|
33
|
+
* which is almost never the shopper's — pass it if the shop serves more than
|
|
34
|
+
* one. Ignored when `formatPrice` and `formatBundlePrice` are supplied.
|
|
35
|
+
*/
|
|
36
|
+
locale?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Adds the model's own reasoning, the provider and the model name to the
|
|
39
|
+
* markup. Useful while developing and while benchmarking; it publishes which
|
|
40
|
+
* vendor a shop uses and whether the component is currently degraded, so it
|
|
41
|
+
* is off unless asked for.
|
|
42
|
+
*/
|
|
43
|
+
hasDiagnostics?: boolean;
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
/** A list of products, or anything keyed by SKU that answers `get` and `has`. */
|
|
47
|
+
export type ProductCatalog = readonly Product[] | ReadonlyMap<string, Product>;
|
|
48
|
+
/**
|
|
49
|
+
* Renders a component specification.
|
|
50
|
+
*
|
|
51
|
+
* A Server Component: no hooks, no state, no effects, and therefore no client
|
|
52
|
+
* bundle and no hydration for the recommendation area. The whole component
|
|
53
|
+
* arrives in the initial HTML response, which is what removes the pop-in a
|
|
54
|
+
* client-fetched recommendation rail has — and what makes the content visible
|
|
55
|
+
* to a crawler that does not run JavaScript.
|
|
56
|
+
*
|
|
57
|
+
* Renders nothing at all when no block produced markup.
|
|
58
|
+
*/
|
|
59
|
+
export declare function RudraComponent({ spec, products, bundles, registry, hrefForSku, formatPrice, formatBundlePrice, locale, hasDiagnostics, className, }: RudraComponentProps): import("react").JSX.Element | null;
|
|
60
|
+
//# sourceMappingURL=rudra-component.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rudra-component.d.ts","sourceRoot":"","sources":["../src/rudra-component.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAO5E,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAEpE,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,aAAa,CAAC;IACpB;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,EAAE,cAAc,CAAC;IACzB,oFAAoF;IACpF,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC;IAC3C,0FAA0F;IAC1F,iBAAiB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,iFAAiF;AACjF,MAAM,MAAM,cAAc,GAAG,SAAS,OAAO,EAAE,GAAG,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAmG/E;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,EAC7B,IAAI,EACJ,QAAQ,EACR,OAAO,EACP,QAA0B,EAC1B,UAA8B,EAC9B,WAAW,EACX,iBAAiB,EACjB,MAAM,EACN,cAAsB,EACtB,SAAS,GACV,EAAE,mBAAmB,sCA0DrB"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { defaultFormatBundlePrice, defaultFormatPrice, defaultHrefForSku, } from './render-context.js';
|
|
3
|
+
import { defaultRegistry } from './registry.js';
|
|
4
|
+
/**
|
|
5
|
+
* Whether the catalog is already keyed by SKU.
|
|
6
|
+
*
|
|
7
|
+
* Asks what the renderers actually call rather than which class the host
|
|
8
|
+
* happened to construct. `instanceof Map` was wrong twice over: a Map that
|
|
9
|
+
* crossed a realm boundary — a `node:vm` context, a worker — fails it, and so
|
|
10
|
+
* does a host's own `ReadonlyMap`, which the prop type has always allowed. Both
|
|
11
|
+
* then fell into the list branch, where `catalog.map is not a function` throws
|
|
12
|
+
* while the render context is being built, before any block renders. That takes
|
|
13
|
+
* down the whole page, not just this component.
|
|
14
|
+
*
|
|
15
|
+
* Keyed before list, because a collection can answer both: an Immutable.js map
|
|
16
|
+
* has `map`, and converting through it yields a catalog whose every value is a
|
|
17
|
+
* `[sku, product]` pair rather than a product.
|
|
18
|
+
*/
|
|
19
|
+
function isKeyedBySku(catalog) {
|
|
20
|
+
const candidate = catalog;
|
|
21
|
+
return typeof candidate.get === 'function' && typeof candidate.has === 'function';
|
|
22
|
+
}
|
|
23
|
+
function toProductMap(catalog) {
|
|
24
|
+
if (isKeyedBySku(catalog))
|
|
25
|
+
return catalog;
|
|
26
|
+
if (typeof catalog.map !== 'function') {
|
|
27
|
+
// A Set of products, a plain object keyed by SKU, a Map that has been
|
|
28
|
+
// through JSON. Refusing here names the prop while the stack still points
|
|
29
|
+
// at it. Carried through instead, a grid renders nothing and a banner
|
|
30
|
+
// renders a healthy-looking page, and the shop finds out from a dashboard.
|
|
31
|
+
throw new TypeError('the `products` prop must be a list of products, or keyed by SKU with `get` and `has` — ' +
|
|
32
|
+
`received ${Object.prototype.toString.call(catalog)}`);
|
|
33
|
+
}
|
|
34
|
+
return new Map(catalog.map((product) => [product.sku, product]));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether a block still has anything to say once the catalog is applied.
|
|
38
|
+
*
|
|
39
|
+
* Three block kinds can come up empty: reconciliation ran against the catalog
|
|
40
|
+
* as it was when the spec was generated, and a SKU can sell out between then
|
|
41
|
+
* and this render. Grid and carousel lose just the products that did; a
|
|
42
|
+
* bundle loses itself entirely if any one of its members did. The rest carry
|
|
43
|
+
* their own words.
|
|
44
|
+
*/
|
|
45
|
+
function hasContent(block, products, bundles) {
|
|
46
|
+
switch (block.kind) {
|
|
47
|
+
case 'grid':
|
|
48
|
+
case 'carousel':
|
|
49
|
+
return block.items.some((reference) => products.has(reference.sku));
|
|
50
|
+
case 'hero':
|
|
51
|
+
case 'banner':
|
|
52
|
+
case 'copy':
|
|
53
|
+
return true;
|
|
54
|
+
case 'bundle': {
|
|
55
|
+
if (block.bundleId === null)
|
|
56
|
+
return false;
|
|
57
|
+
const bundle = bundles.get(block.bundleId);
|
|
58
|
+
return bundle !== undefined && bundle.skus.every((sku) => products.has(sku));
|
|
59
|
+
}
|
|
60
|
+
default:
|
|
61
|
+
// A kind this renderer predates renders nothing, so it counts as nothing.
|
|
62
|
+
block;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function renderBlock(block, context, registry, index) {
|
|
67
|
+
switch (block.kind) {
|
|
68
|
+
case 'hero':
|
|
69
|
+
return _jsx(registry.hero, { block: block, context: context }, index);
|
|
70
|
+
case 'grid':
|
|
71
|
+
return _jsx(registry.grid, { block: block, context: context }, index);
|
|
72
|
+
case 'carousel':
|
|
73
|
+
return _jsx(registry.carousel, { block: block, context: context }, index);
|
|
74
|
+
case 'banner':
|
|
75
|
+
return _jsx(registry.banner, { block: block, context: context }, index);
|
|
76
|
+
case 'copy':
|
|
77
|
+
return _jsx(registry.copy, { block: block, context: context }, index);
|
|
78
|
+
case 'bundle':
|
|
79
|
+
return _jsx(registry.bundle, { block: block, context: context }, index);
|
|
80
|
+
default:
|
|
81
|
+
// A newer core carrying a block kind this renderer predates loses that
|
|
82
|
+
// block rather than the page. In this repo the assertion below fails the
|
|
83
|
+
// build instead, which is the moment it is cheap to notice.
|
|
84
|
+
block;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Renders a component specification.
|
|
90
|
+
*
|
|
91
|
+
* A Server Component: no hooks, no state, no effects, and therefore no client
|
|
92
|
+
* bundle and no hydration for the recommendation area. The whole component
|
|
93
|
+
* arrives in the initial HTML response, which is what removes the pop-in a
|
|
94
|
+
* client-fetched recommendation rail has — and what makes the content visible
|
|
95
|
+
* to a crawler that does not run JavaScript.
|
|
96
|
+
*
|
|
97
|
+
* Renders nothing at all when no block produced markup.
|
|
98
|
+
*/
|
|
99
|
+
export function RudraComponent({ spec, products, bundles, registry = defaultRegistry, hrefForSku = defaultHrefForSku, formatPrice, formatBundlePrice, locale, hasDiagnostics = false, className, }) {
|
|
100
|
+
const productMap = toProductMap(products);
|
|
101
|
+
const bundlesById = new Map((bundles ?? []).map((bundle) => [bundle.id, bundle]));
|
|
102
|
+
const context = {
|
|
103
|
+
products: productMap,
|
|
104
|
+
bundles: bundlesById,
|
|
105
|
+
hrefForSku,
|
|
106
|
+
formatPrice: formatPrice ?? ((product) => defaultFormatPrice(product, locale)),
|
|
107
|
+
formatBundlePrice: formatBundlePrice ?? ((bundle) => defaultFormatBundlePrice(bundle, locale)),
|
|
108
|
+
};
|
|
109
|
+
// An empty recommendation area is worse than none: it takes up space and
|
|
110
|
+
// tells the shopper the page is broken. That includes the subtler version —
|
|
111
|
+
// a headline and an empty box, because every product in the spec has sold out
|
|
112
|
+
// since it was generated — which is why this asks what is left rather than
|
|
113
|
+
// how many blocks arrived.
|
|
114
|
+
const visible = spec.blocks.filter((block) => hasContent(block, context.products, context.bundles));
|
|
115
|
+
if (visible.length === 0)
|
|
116
|
+
return null;
|
|
117
|
+
// React omits a data-* attribute whose value is undefined, so degradedReason
|
|
118
|
+
// needs no branch of its own.
|
|
119
|
+
const diagnosticAttributes = hasDiagnostics
|
|
120
|
+
? {
|
|
121
|
+
'data-rudra-provider': spec.provider ?? 'none',
|
|
122
|
+
'data-rudra-model': spec.model ?? 'none',
|
|
123
|
+
'data-rudra-latency-ms': String(spec.latencyMs),
|
|
124
|
+
'data-rudra-degraded': spec.degradedReason,
|
|
125
|
+
}
|
|
126
|
+
: undefined;
|
|
127
|
+
return (_jsxs("section", {
|
|
128
|
+
// Extended rather than replaced: every child class is namespaced under
|
|
129
|
+
// `rudra`, and the package ships no stylesheet, so a host will pass one.
|
|
130
|
+
className: className ? `rudra ${className}` : 'rudra', "data-rudra-slot": spec.slot, "data-rudra-source": spec.source, "data-rudra-tone": spec.tone, ...diagnosticAttributes, children: [_jsxs("header", { className: "rudra__header", children: [_jsx("h2", { className: "rudra__headline", children: spec.headline }), spec.subheadline ? _jsx("p", { className: "rudra__subheadline", children: spec.subheadline }) : null] }), visible.map((block, index) => renderBlock(block, context, registry, index)), hasDiagnostics ? _jsx("p", { className: "rudra__rationale", children: spec.rationale }) : null] }));
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=rudra-component.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rudra-component.js","sourceRoot":"","sources":["../src/rudra-component.tsx"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rudra-js/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Renders a rudra component specification as React Server Components",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react",
|
|
7
|
+
"react-server-components",
|
|
8
|
+
"rsc",
|
|
9
|
+
"llm",
|
|
10
|
+
"recommendations",
|
|
11
|
+
"ecommerce",
|
|
12
|
+
"ssr"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Clive Dsouza",
|
|
16
|
+
"homepage": "https://github.com/clivedsouza1010/rudra-js/tree/main/packages/react#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/clivedsouza1010/rudra-js/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/clivedsouza1010/rudra-js.git",
|
|
23
|
+
"directory": "packages/react"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src/**/*.ts",
|
|
41
|
+
"src/**/*.tsx",
|
|
42
|
+
"!src/**/*.test.ts",
|
|
43
|
+
"!src/**/*.test.tsx"
|
|
44
|
+
],
|
|
45
|
+
"sideEffects": false,
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.json",
|
|
48
|
+
"prepack": "npm run build"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@rudra-js/core": "^0.1.0",
|
|
55
|
+
"react": "^19.0.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@rudra-js/core": "0.1.0",
|
|
59
|
+
"@types/react": "^19.2.18",
|
|
60
|
+
"@types/react-dom": "^19.2.5",
|
|
61
|
+
"react": "^19.2.8",
|
|
62
|
+
"react-dom": "^19.2.8"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Five of the six block renderers. The bundle one lives in `bundle-block.tsx`,
|
|
3
|
+
* next door, since it draws a set the shop picked rather than the model.
|
|
4
|
+
*
|
|
5
|
+
* Each takes a block the model produced and a context the host owns. None of
|
|
6
|
+
* them interpolate markup, and none of them read a product fact from the block
|
|
7
|
+
* — that is what keeps generated output to the role of deciding layout, order
|
|
8
|
+
* and wording.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { BannerBlock, CarouselBlock, CopyBlock, GridBlock, HeroBlock } from '@rudra-js/core';
|
|
12
|
+
import type { BlockRenderContext } from '../render-context.js';
|
|
13
|
+
import { ProductCard } from './product-card.js';
|
|
14
|
+
|
|
15
|
+
export function HeroRenderer({
|
|
16
|
+
block,
|
|
17
|
+
context,
|
|
18
|
+
}: {
|
|
19
|
+
block: HeroBlock;
|
|
20
|
+
context: BlockRenderContext;
|
|
21
|
+
}) {
|
|
22
|
+
const product = block.sku ? context.products.get(block.sku) : undefined;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<section className="rudra-hero">
|
|
26
|
+
<h3 className="rudra-hero__headline">{block.headline}</h3>
|
|
27
|
+
{block.body ? <p className="rudra-hero__body">{block.body}</p> : null}
|
|
28
|
+
{product ? (
|
|
29
|
+
<a
|
|
30
|
+
className="rudra-hero__link"
|
|
31
|
+
href={context.hrefForSku(product.sku)}
|
|
32
|
+
data-rudra-sku={product.sku}
|
|
33
|
+
>
|
|
34
|
+
{product.title}
|
|
35
|
+
<span className="rudra-hero__price">{context.formatPrice(product)}</span>
|
|
36
|
+
</a>
|
|
37
|
+
) : null}
|
|
38
|
+
{/* Only alongside a product: a "Shop now" with no destination is worse
|
|
39
|
+
than no call to action, and the model can set one for a SKU the
|
|
40
|
+
catalog does not have. */}
|
|
41
|
+
{product && block.ctaLabel ? <span className="rudra-hero__cta">{block.ctaLabel}</span> : null}
|
|
42
|
+
</section>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function GridRenderer({
|
|
47
|
+
block,
|
|
48
|
+
context,
|
|
49
|
+
}: {
|
|
50
|
+
block: GridBlock;
|
|
51
|
+
context: BlockRenderContext;
|
|
52
|
+
}) {
|
|
53
|
+
return (
|
|
54
|
+
<section className="rudra-grid" data-rudra-columns={block.columns}>
|
|
55
|
+
{block.title ? <h3 className="rudra-grid__title">{block.title}</h3> : null}
|
|
56
|
+
<div className="rudra-grid__items">
|
|
57
|
+
{block.items.map((reference) => (
|
|
58
|
+
<ProductCard key={reference.sku} reference={reference} context={context} />
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function CarouselRenderer({
|
|
66
|
+
block,
|
|
67
|
+
context,
|
|
68
|
+
}: {
|
|
69
|
+
block: CarouselBlock;
|
|
70
|
+
context: BlockRenderContext;
|
|
71
|
+
}) {
|
|
72
|
+
return (
|
|
73
|
+
<section className="rudra-carousel">
|
|
74
|
+
{block.title ? <h3 className="rudra-carousel__title">{block.title}</h3> : null}
|
|
75
|
+
{/* Scrolls with CSS overflow rather than JavaScript, so the whole
|
|
76
|
+
component still needs no client bundle. */}
|
|
77
|
+
<div className="rudra-carousel__track">
|
|
78
|
+
{block.items.map((reference) => (
|
|
79
|
+
<ProductCard key={reference.sku} reference={reference} context={context} />
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</section>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function BannerRenderer({ block }: { block: BannerBlock }) {
|
|
87
|
+
return (
|
|
88
|
+
<aside className="rudra-banner" data-rudra-banner-tone={block.tone}>
|
|
89
|
+
<span className="rudra-banner__text">{block.text}</span>
|
|
90
|
+
{block.ctaLabel ? <span className="rudra-banner__cta">{block.ctaLabel}</span> : null}
|
|
91
|
+
</aside>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function CopyRenderer({ block }: { block: CopyBlock }) {
|
|
96
|
+
return (
|
|
97
|
+
<section className="rudra-copy">
|
|
98
|
+
{block.title ? <h3 className="rudra-copy__title">{block.title}</h3> : null}
|
|
99
|
+
<p className="rudra-copy__body">{block.body}</p>
|
|
100
|
+
</section>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { BundleBlock, Product } from '@rudra-js/core';
|
|
2
|
+
import type { BlockRenderContext } from '../render-context.js';
|
|
3
|
+
|
|
4
|
+
export function BundleRenderer({
|
|
5
|
+
block,
|
|
6
|
+
context,
|
|
7
|
+
}: {
|
|
8
|
+
block: BundleBlock;
|
|
9
|
+
context: BlockRenderContext;
|
|
10
|
+
}) {
|
|
11
|
+
const bundle = block.bundleId === null ? undefined : context.bundles.get(block.bundleId);
|
|
12
|
+
if (!bundle) return null;
|
|
13
|
+
|
|
14
|
+
const products: Product[] = [];
|
|
15
|
+
for (const sku of bundle.skus) {
|
|
16
|
+
const product = context.products.get(sku);
|
|
17
|
+
// A set missing one of its parts is not that set.
|
|
18
|
+
if (!product) return null;
|
|
19
|
+
products.push(product);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<section className="rudra-bundle">
|
|
24
|
+
{/* The shop's label is the one name here anyone can check, so it leads. */}
|
|
25
|
+
{bundle.label ? <h3 className="rudra-bundle__label">{bundle.label}</h3> : null}
|
|
26
|
+
{block.title ? <p className="rudra-bundle__title">{block.title}</p> : null}
|
|
27
|
+
{block.body ? <p className="rudra-bundle__body">{block.body}</p> : null}
|
|
28
|
+
|
|
29
|
+
<ul className="rudra-bundle__items">
|
|
30
|
+
{products.map((product) => (
|
|
31
|
+
<li key={product.sku} className="rudra-bundle__item" data-rudra-sku={product.sku}>
|
|
32
|
+
<a className="rudra-bundle__link" href={context.hrefForSku(product.sku)}>
|
|
33
|
+
{product.title}
|
|
34
|
+
</a>
|
|
35
|
+
</li>
|
|
36
|
+
))}
|
|
37
|
+
</ul>
|
|
38
|
+
|
|
39
|
+
<p className="rudra-bundle__price">{context.formatBundlePrice(bundle)}</p>
|
|
40
|
+
{block.ctaLabel ? <span className="rudra-bundle__cta">{block.ctaLabel}</span> : null}
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ProductReference } from '@rudra-js/core';
|
|
2
|
+
import type { BlockRenderContext } from '../render-context.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One product.
|
|
6
|
+
*
|
|
7
|
+
* Every fact here — the title, the price, the image, the link — is read from
|
|
8
|
+
* the catalog, not from the specification. The only things the model
|
|
9
|
+
* contributes are `reason`, `badge` and `emphasis`, and React escapes all three
|
|
10
|
+
* on the way into the markup.
|
|
11
|
+
*/
|
|
12
|
+
export function ProductCard({
|
|
13
|
+
reference,
|
|
14
|
+
context,
|
|
15
|
+
}: {
|
|
16
|
+
reference: ProductReference;
|
|
17
|
+
context: BlockRenderContext;
|
|
18
|
+
}) {
|
|
19
|
+
const product = context.products.get(reference.sku);
|
|
20
|
+
// Reconciliation drops a SKU the catalog does not have, so this should be
|
|
21
|
+
// unreachable. Rendering nothing beats rendering a card with holes in it.
|
|
22
|
+
if (!product) return null;
|
|
23
|
+
|
|
24
|
+
const isFeatured = reference.emphasis === 'featured';
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<a
|
|
28
|
+
href={context.hrefForSku(product.sku)}
|
|
29
|
+
data-rudra-sku={product.sku}
|
|
30
|
+
data-rudra-basis={reference.basis}
|
|
31
|
+
className={isFeatured ? 'rudra-card rudra-card--featured' : 'rudra-card'}
|
|
32
|
+
>
|
|
33
|
+
{product.imageUrl ? (
|
|
34
|
+
<img className="rudra-card__image" src={product.imageUrl} alt="" loading="lazy" />
|
|
35
|
+
) : null}
|
|
36
|
+
|
|
37
|
+
<span className="rudra-card__body">
|
|
38
|
+
{reference.badge ? <span className="rudra-card__badge">{reference.badge}</span> : null}
|
|
39
|
+
<span className="rudra-card__title">{product.title}</span>
|
|
40
|
+
<span className="rudra-card__price">{context.formatPrice(product)}</span>
|
|
41
|
+
{reference.reason ? <span className="rudra-card__reason">{reference.reason}</span> : null}
|
|
42
|
+
</span>
|
|
43
|
+
</a>
|
|
44
|
+
);
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rudra-js/react — renders a component specification as React Server Components.
|
|
3
|
+
*
|
|
4
|
+
* Carries no client JavaScript. The recommendation area arrives in the initial
|
|
5
|
+
* HTML response and needs no hydration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
RudraComponent,
|
|
10
|
+
type ProductCatalog,
|
|
11
|
+
type RudraComponentProps,
|
|
12
|
+
} from './rudra-component.js';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
defaultRegistry,
|
|
16
|
+
extendRegistry,
|
|
17
|
+
type BlockRegistry,
|
|
18
|
+
type BlockRenderer,
|
|
19
|
+
} from './registry.js';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
defaultFormatBundlePrice,
|
|
23
|
+
defaultFormatPrice,
|
|
24
|
+
defaultHrefForSku,
|
|
25
|
+
type BlockRenderContext,
|
|
26
|
+
} from './render-context.js';
|
|
27
|
+
|
|
28
|
+
// The six default renderers reach hosts through `defaultRegistry`, which is
|
|
29
|
+
// also the thing they need in order to override one. `ProductCard` is exported
|
|
30
|
+
// on its own because a custom grid renderer still wants the standard card.
|
|
31
|
+
export { ProductCard } from './blocks/product-card.js';
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each kind of block actually renders.
|
|
3
|
+
*
|
|
4
|
+
* This is the seam that makes a generated component safe *and* usable. The
|
|
5
|
+
* model picks from a fixed vocabulary of block kinds; the registry decides what
|
|
6
|
+
* those kinds look like. A shop can swap any entry for its own design-system
|
|
7
|
+
* component without the model knowing, without the specification changing, and
|
|
8
|
+
* without giving the model any new ability — it still cannot say anything
|
|
9
|
+
* outside the vocabulary.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
import type { Block, BlockKind } from '@rudra-js/core';
|
|
14
|
+
import type { BlockRenderContext } from './render-context.js';
|
|
15
|
+
import {
|
|
16
|
+
BannerRenderer,
|
|
17
|
+
CarouselRenderer,
|
|
18
|
+
CopyRenderer,
|
|
19
|
+
GridRenderer,
|
|
20
|
+
HeroRenderer,
|
|
21
|
+
} from './blocks/block-renderers.js';
|
|
22
|
+
import { BundleRenderer } from './blocks/bundle-block.js';
|
|
23
|
+
|
|
24
|
+
export type BlockRenderer<Kind extends BlockKind> = (props: {
|
|
25
|
+
block: Extract<Block, { kind: Kind }>;
|
|
26
|
+
context: BlockRenderContext;
|
|
27
|
+
}) => ReactNode;
|
|
28
|
+
|
|
29
|
+
export type BlockRegistry = {
|
|
30
|
+
[Kind in BlockKind]: BlockRenderer<Kind>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const defaultRegistry: BlockRegistry = {
|
|
34
|
+
hero: HeroRenderer,
|
|
35
|
+
grid: GridRenderer,
|
|
36
|
+
carousel: CarouselRenderer,
|
|
37
|
+
banner: BannerRenderer,
|
|
38
|
+
copy: CopyRenderer,
|
|
39
|
+
bundle: BundleRenderer,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Replaces some renderers and keeps the rest.
|
|
44
|
+
*
|
|
45
|
+
* Written out kind by kind rather than spread: a spread lets
|
|
46
|
+
* `{ hero: maybeRenderer }` put an explicit `undefined` over the default, and
|
|
47
|
+
* the page then renders every hero as nothing. Here an absent override means
|
|
48
|
+
* the default, however it was written.
|
|
49
|
+
*/
|
|
50
|
+
export function extendRegistry(overrides: Partial<BlockRegistry>): BlockRegistry {
|
|
51
|
+
return {
|
|
52
|
+
hero: overrides.hero ?? defaultRegistry.hero,
|
|
53
|
+
grid: overrides.grid ?? defaultRegistry.grid,
|
|
54
|
+
carousel: overrides.carousel ?? defaultRegistry.carousel,
|
|
55
|
+
banner: overrides.banner ?? defaultRegistry.banner,
|
|
56
|
+
copy: overrides.copy ?? defaultRegistry.copy,
|
|
57
|
+
bundle: overrides.bundle ?? defaultRegistry.bundle,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Bundle, Product } from '@rudra-js/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Everything a block renderer needs that does not come from the specification.
|
|
5
|
+
*
|
|
6
|
+
* Note what is absent from the spec and present here: a price, a title, an
|
|
7
|
+
* image, a link. Those are resolved from the host's own catalog, keyed by a SKU
|
|
8
|
+
* that `selectProducts` drew from that same catalog and `reconcileSpec` proved
|
|
9
|
+
* the model did not invent — both in stock at the time. A bundle member's SKU
|
|
10
|
+
* comes from the set the shop supplied, not from the model. That division
|
|
11
|
+
* is the whole reason a generated component is safe to put in a page — the
|
|
12
|
+
* model decides what to show and how to describe it, and the shop decides what
|
|
13
|
+
* is true about a product.
|
|
14
|
+
*/
|
|
15
|
+
export interface BlockRenderContext {
|
|
16
|
+
/** The host's catalog, keyed by SKU. Read-only: it is the caller's own map. */
|
|
17
|
+
readonly products: ReadonlyMap<string, Product>;
|
|
18
|
+
/** Sets the shop sells together, keyed by id. */
|
|
19
|
+
readonly bundles: ReadonlyMap<string, Bundle>;
|
|
20
|
+
/** Host-owned link construction. */
|
|
21
|
+
readonly hrefForSku: (sku: string) => string;
|
|
22
|
+
readonly formatPrice: (product: Product) => string;
|
|
23
|
+
readonly formatBundlePrice: (bundle: Bundle) => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function defaultHrefForSku(sku: string): string {
|
|
27
|
+
return `/product/${encodeURIComponent(sku)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Formats a price the way the currency itself is written.
|
|
32
|
+
*
|
|
33
|
+
* No digit count is specified on purpose. Two decimal places is a dollar-and-
|
|
34
|
+
* cent assumption, and forcing it is wrong in both directions: Kuwaiti dinar and
|
|
35
|
+
* Bahraini dinar have three, so a real digit of the price disappears, and yen has
|
|
36
|
+
* none, so a price gains a fraction that does not exist. Intl already knows the
|
|
37
|
+
* right number for each currency.
|
|
38
|
+
*
|
|
39
|
+
* `locale` decides how the number is punctuated and where the symbol sits.
|
|
40
|
+
* Left undefined it falls back to the server's locale, which is almost never
|
|
41
|
+
* the shopper's — a shop serving more than one should pass the shopper's.
|
|
42
|
+
*/
|
|
43
|
+
export function defaultFormatPrice(product: Product, locale?: string): string {
|
|
44
|
+
// A price that is not a finite number is a broken catalog, not a formatting
|
|
45
|
+
// problem, and Intl will happily render it: null becomes 0 and prints as a
|
|
46
|
+
// free product, undefined prints as NaN. Neither belongs on a shop page, and
|
|
47
|
+
// both mean the catalog skipped the validation this package documents as a
|
|
48
|
+
// precondition. Failing here is a bug report; rendering it is an incident.
|
|
49
|
+
if (!Number.isFinite(product.price)) {
|
|
50
|
+
throw new TypeError(
|
|
51
|
+
`price for SKU ${product.sku} is ${String(product.price)}, not a finite number — ` +
|
|
52
|
+
'catalog objects must satisfy productSchema from @rudra-js/core',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let formatter: Intl.NumberFormat;
|
|
57
|
+
try {
|
|
58
|
+
// Only the constructor is guarded. Wrapping format() as well would swallow
|
|
59
|
+
// a throwing getter on a host's own Product object and render the resulting
|
|
60
|
+
// mess as a price.
|
|
61
|
+
formatter = new Intl.NumberFormat(locale, { style: 'currency', currency: product.currency });
|
|
62
|
+
} catch {
|
|
63
|
+
// Two things reach this. A malformed `locale`, which is a host prop nothing
|
|
64
|
+
// validates. And a malformed `currency` — which productSchema would have
|
|
65
|
+
// rejected, but this package takes a catalog directly, so a hand-built
|
|
66
|
+
// object can carry one. Intl accepts any three-letter code it does not
|
|
67
|
+
// know, so 'ZZZ' formats; 'us$' and '' do not. A price nobody can
|
|
68
|
+
// punctuate is still a price worth showing.
|
|
69
|
+
return `${product.currency} ${product.price}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return formatter.format(product.price);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Formats a bundle's price, in the currency the shop put on the bundle.
|
|
77
|
+
*
|
|
78
|
+
* The price and the currency come from the same object, so a set whose members
|
|
79
|
+
* are priced in another currency still shows the shop's own price correctly.
|
|
80
|
+
*/
|
|
81
|
+
export function defaultFormatBundlePrice(bundle: Bundle, locale?: string): 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
|
+
|
|
89
|
+
try {
|
|
90
|
+
return new Intl.NumberFormat(locale, {
|
|
91
|
+
style: 'currency',
|
|
92
|
+
currency: bundle.currency,
|
|
93
|
+
}).format(bundle.price);
|
|
94
|
+
} catch {
|
|
95
|
+
// A bad locale, or a currency code Intl rejects on a hand-built bundle,
|
|
96
|
+
// should not crash the page. A price nobody can punctuate is still a price.
|
|
97
|
+
return `${bundle.currency} ${bundle.price}`;
|
|
98
|
+
}
|
|
99
|
+
}
|