@evanion/react-widget 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/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-08-26)
4
+
5
+ First published release of `@evanion/react-widget`.
6
+
7
+ Still 0.x deliberately: the API works and is type-checked, but has not been
8
+ exercised by real consumers yet, so it may still move.
9
+
10
+ ### 🚀 Features
11
+
12
+ - `createWidgets` infers from the component map you give it. An item's `type`
13
+ must be a key of the map, and its `props` are checked against that component,
14
+ so an unknown widget type is a compile error rather than a runtime warning.
15
+ - `defineItems` helper, for item arrays declared in a variable — a bare array
16
+ literal widens `type` to `string` and silently loses the check.
17
+ - Nested widgets via an injected `<Output />`, to any depth.
18
+ - Custom chrome (`wrapper` and `item`), overridable per instance.
19
+ - Built-in error boundary and Suspense fallback per widget.
20
+
21
+ ### 🩹 Fixes
22
+
23
+ - Nesting silently stopped at depth 2 — `Output` never passed an `Output` to
24
+ grandchildren, so the third level was dropped.
25
+ - The unknown-type guard used `in`, which walks the prototype chain, so a
26
+ CMS-supplied `type` of `constructor` or `toString` crashed the render instead
27
+ of warning and skipping.
28
+ - Nested subtrees remounted on every parent render, discarding child state,
29
+ effects and focus, because a new `Output` component type was built each time.
30
+ - Nested widgets ignored instance-level `chrome`.
31
+ - `DefaultWrapper` and `DefaultItem` were documented but never exported.
32
+
33
+ ### 📦 Packaging
34
+
35
+ - Declared a `react` peer dependency (`^18 || ^19`); there was previously no
36
+ dependency information at all.
37
+ - Added `LICENSE`, `repository`, `engines` and `sideEffects`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mikael Pettersson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,424 @@
1
+ # @evanion/react-widget
2
+
3
+ A powerful React library for creating dynamic, reusable widget regions from structured data. Perfect for building CMS-driven layouts, dynamic sidebars, dashboards, and any interface that needs to render different components based on configuration data.
4
+
5
+ ## Features
6
+
7
+ - 🎯 **Type-safe**: `createWidgets` infers from your component map, so an unknown
8
+ widget `type` or mismatched `props` is a compile error, not a runtime warning
9
+ - 🔧 **Flexible**: Support for custom chrome components and wrappers
10
+ - ⚡ **Lightweight**: Minimal bundle size with zero dependencies
11
+ - 🎨 **Customizable**: Easy theming and styling through wrapper components
12
+ - 🔄 **Context-aware**: Built-in React Context support for component sharing
13
+ - 📦 **Tree-shakable**: Only import what you need
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @evanion/react-widget
19
+ # or
20
+ yarn add @evanion/react-widget
21
+ # or
22
+ pnpm add @evanion/react-widget
23
+ ```
24
+
25
+ ## Typing your items
26
+
27
+ `createWidgets` infers the allowed `type` values and each item's `props` from the
28
+ component map you give it.
29
+
30
+ ```tsx
31
+ const { Widgets, defineItems } = createWidgets({
32
+ components: { news: NewsTeaser, weather: WeatherCard },
33
+ });
34
+
35
+ const items = defineItems([
36
+ { id: '1', type: 'news', props: { title: 'Hello' } },
37
+ { id: '2', type: 'nope', props: {} }, // ✗ 'nope' is not in the component map
38
+ { id: '3', type: 'weather', props: { celsius: 'warm' } }, // ✗ celsius is a number
39
+ ]);
40
+ ```
41
+
42
+ `defineItems` is an identity function that exists purely to supply the contextual
43
+ type. A bare `const items = [{ type: 'news', ... }]` widens `type` to `string`,
44
+ which cannot narrow to the map's keys, and the check is silently lost. Writing
45
+ the array inline in JSX works too — that is already contextually typed.
46
+
47
+ ## Quick Start
48
+
49
+ ```tsx
50
+ import { createWidgets } from '@evanion/react-widget';
51
+ import { PropsWithChildren } from 'react';
52
+
53
+ // Define your widget components
54
+ const NewsTeaser = ({ title, publishedAt, body }: NewsProps) => (
55
+ <article>
56
+ <h3>{title}</h3>
57
+ <time>{publishedAt.toLocaleDateString()}</time>
58
+ <p>{body}</p>
59
+ </article>
60
+ );
61
+
62
+ const UserSidebar = ({ username, avatar, messages }: UserProps) => (
63
+ <div className="user-info">
64
+ <img src={avatar} alt={username} />
65
+ <span>{username}</span>
66
+ <span>{messages} messages</span>
67
+ </div>
68
+ );
69
+
70
+ // Create your widget configuration
71
+ const { Widgets } = createWidgets({
72
+ components: {
73
+ news: NewsTeaser,
74
+ userInfo: UserSidebar,
75
+ },
76
+ chrome: {
77
+ wrapper: ({ children }: PropsWithChildren) => (
78
+ <aside className="sidebar">{children}</aside>
79
+ ),
80
+ },
81
+ });
82
+
83
+ // Define your widget data
84
+ const items = [
85
+ {
86
+ id: 'userinfo',
87
+ type: 'userInfo',
88
+ props: {
89
+ username: 'Evanion',
90
+ avatar: 'https://evanion.com/avatar.jpg',
91
+ messages: 5,
92
+ },
93
+ },
94
+ {
95
+ id: 'news1',
96
+ type: 'news',
97
+ props: {
98
+ title: 'Breaking News',
99
+ publishedAt: new Date(),
100
+ body: 'This is a sample news article...',
101
+ },
102
+ },
103
+ ];
104
+
105
+ // Use in your layout
106
+ function MyLayout({ children }: PropsWithChildren) {
107
+ return (
108
+ <main>
109
+ <article>{children}</article>
110
+ <Widgets items={items} />
111
+ </main>
112
+ );
113
+ }
114
+ ```
115
+
116
+ ## API Reference
117
+
118
+ ### `createWidgets<Items>(config)`
119
+
120
+ Creates a widget system with the given configuration.
121
+
122
+ #### Parameters
123
+
124
+ - `config.components` - Object mapping widget types to React components
125
+ - `config.chrome` - Optional wrapper components for styling
126
+ - `config.context` - Optional React Context for component sharing
127
+
128
+ #### Returns
129
+
130
+ - `Widgets` - Component for rendering widget items
131
+ - `WidgetsProvider` - Context provider for sharing components
132
+ - `useWidgets` - Hook for accessing widget components in context
133
+
134
+ ### `Widgets` Component
135
+
136
+ Renders a list of widget items.
137
+
138
+ #### Props
139
+
140
+ - `items` - Array of widget items to render
141
+ - `components` - Optional override components for this instance
142
+ - `chrome` - Optional chrome overrides for this instance
143
+
144
+ ### Widget Item Structure
145
+
146
+ ```tsx
147
+ interface WidgetItem {
148
+ id: string; // Unique identifier
149
+ type: string; // Widget type -- must be a key of your component map
150
+ props: object; // Props for that component, checked against it
151
+ children?: WidgetItem[]; // Nested widgets, rendered by the injected <Output />
152
+ }
153
+ ```
154
+
155
+ ## Advanced Usage
156
+
157
+ ### Custom Chrome Components
158
+
159
+ ```tsx
160
+ const { Widgets } = createWidgets({
161
+ components: {/* your components */},
162
+ chrome: {
163
+ wrapper: ({ children }) => (
164
+ <div className="widget-container">
165
+ <header>My Widgets</header>
166
+ <div className="widget-content">{children}</div>
167
+ </div>
168
+ ),
169
+ item: ({ children }) => <div className="widget-item">{children}</div>,
170
+ },
171
+ });
172
+ ```
173
+
174
+ ### Using Context for Component Sharing
175
+
176
+ ```tsx
177
+ import { createContext } from 'react';
178
+
179
+ const MyWidgetContext = createContext({});
180
+
181
+ const { Widgets, WidgetsProvider } = createWidgets({
182
+ components: {/* your components */},
183
+ context: MyWidgetContext,
184
+ });
185
+
186
+ // Use the context in your app
187
+ function App() {
188
+ return (
189
+ <WidgetsProvider>
190
+ <MyLayout />
191
+ </WidgetsProvider>
192
+ );
193
+ }
194
+ ```
195
+
196
+ ### Instance-specific Component Overrides
197
+
198
+ ```tsx
199
+ function MyPage() {
200
+ const specialNewsComponent = ({ title, ...props }) => (
201
+ <div className="featured-news">
202
+ <h2>Featured: {title}</h2>
203
+ </div>
204
+ );
205
+
206
+ return <Widgets items={items} components={{ news: specialNewsComponent }} />;
207
+ }
208
+ ```
209
+
210
+ ### Nested Widgets
211
+
212
+ Use the `<Output />` component to render nested widgets within your components:
213
+
214
+ ```tsx
215
+ const { Widgets, Output } = createWidgets({
216
+ components: {
217
+ card: CardWidget,
218
+ text: TextWidget,
219
+ },
220
+ });
221
+
222
+ // Card component that can contain nested widgets
223
+ const CardWidget = ({ title, Output }) => (
224
+ <div className="card">
225
+ <h3>{title}</h3>
226
+ <Output />
227
+ </div>
228
+ );
229
+
230
+ const TextWidget = ({ content }) => <p>{content}</p>;
231
+
232
+ // Usage with nested widgets
233
+ const items = [
234
+ {
235
+ id: 'card1',
236
+ type: 'card',
237
+ props: {
238
+ title: 'My Card',
239
+ },
240
+ children: [
241
+ {
242
+ id: 'text1',
243
+ type: 'text',
244
+ props: { content: 'This is nested content' },
245
+ },
246
+ ],
247
+ },
248
+ ];
249
+ ```
250
+
251
+ ### Error Handling & Performance
252
+
253
+ The library includes built-in error handling and performance optimizations:
254
+
255
+ #### Error Boundaries
256
+
257
+ Each widget is automatically wrapped in an error boundary that:
258
+
259
+ - Catches rendering errors and displays a fallback UI
260
+ - Logs detailed error information to the console
261
+ - Prevents one failing widget from breaking the entire page
262
+
263
+ #### Suspense Support
264
+
265
+ Widgets support React Suspense for loading states:
266
+
267
+ - Automatic loading fallback for lazy-loaded components
268
+ - Customizable loading UI through chrome components
269
+ - Graceful handling of async operations
270
+
271
+ #### Performance Optimizations
272
+
273
+ - `React.memo` for preventing unnecessary re-renders
274
+ - `useCallback` for stable function references
275
+ - Optimized dependency arrays for `useMemo`
276
+ - Silent error handling with console warnings for unknown widget types
277
+
278
+ #### Unknown Widget Handling
279
+
280
+ When a widget type is not found:
281
+
282
+ - Logs a warning with widget type and ID
283
+ - Silently skips rendering (doesn't break the page)
284
+ - Continues rendering other widgets normally
285
+
286
+ ```tsx
287
+ // Example: Custom error boundary and loading states
288
+ const CustomItem = ({ children, ...props }) => (
289
+ <div {...props}>
290
+ <ErrorBoundary fallback={<div>Custom error UI</div>}>
291
+ <Suspense fallback={<div>Custom loading...</div>}>{children}</Suspense>
292
+ </ErrorBoundary>
293
+ </div>
294
+ );
295
+
296
+ const { Widgets } = createWidgets({
297
+ components: {/* ... */},
298
+ chrome: { item: CustomItem },
299
+ });
300
+ ```
301
+
302
+ ### TypeScript Support
303
+
304
+ The library provides full TypeScript support with type inference:
305
+
306
+ ```tsx
307
+ interface NewsProps {
308
+ title: string;
309
+ publishedAt: Date;
310
+ body: string;
311
+ }
312
+
313
+ interface UserProps {
314
+ username: string;
315
+ avatar: string;
316
+ messages: number;
317
+ }
318
+
319
+ const { Widgets } = createWidgets<{
320
+ news: NewsProps;
321
+ userInfo: UserProps;
322
+ }>({
323
+ components: {
324
+ news: NewsTeaser,
325
+ userInfo: UserSidebar,
326
+ },
327
+ });
328
+
329
+ // TypeScript will now enforce correct prop types
330
+ const items = [
331
+ {
332
+ id: 'news1',
333
+ type: 'news',
334
+ props: {
335
+ title: 'Hello World',
336
+ publishedAt: new Date(),
337
+ body: 'Content here',
338
+ // TypeScript will error if any required props are missing
339
+ },
340
+ },
341
+ ];
342
+ ```
343
+
344
+ ## Use Cases
345
+
346
+ - **CMS-driven layouts**: Render page content based on CMS configuration
347
+ - **Dashboard widgets**: Dynamic dashboard with configurable components
348
+ - **Sidebar content**: Dynamic sidebar with different widget types
349
+ - **Marketing pages**: A/B testing different content layouts
350
+ - **Admin panels**: Configurable admin interface components
351
+ - **E-commerce**: Dynamic product showcases and recommendations
352
+
353
+ ## Examples
354
+
355
+ ### E-commerce Product Showcase
356
+
357
+ ```tsx
358
+ const { Widgets } = createWidgets({
359
+ components: {
360
+ productCard: ProductCard,
361
+ banner: Banner,
362
+ categoryFilter: CategoryFilter,
363
+ },
364
+ chrome: {
365
+ wrapper: ({ children }) => (
366
+ <div className="product-showcase">{children}</div>
367
+ ),
368
+ },
369
+ });
370
+
371
+ const showcaseItems = [
372
+ {
373
+ id: 'banner1',
374
+ type: 'banner',
375
+ props: {
376
+ image: '/banner.jpg',
377
+ title: 'Summer Sale',
378
+ cta: 'Shop Now',
379
+ },
380
+ },
381
+ {
382
+ id: 'filter1',
383
+ type: 'categoryFilter',
384
+ props: {
385
+ categories: ['Electronics', 'Clothing', 'Books'],
386
+ },
387
+ },
388
+ {
389
+ id: 'product1',
390
+ type: 'productCard',
391
+ props: {
392
+ name: 'Wireless Headphones',
393
+ price: 99.99,
394
+ image: '/headphones.jpg',
395
+ },
396
+ },
397
+ ];
398
+ ```
399
+
400
+ ### Blog Layout with Sidebar
401
+
402
+ ```tsx
403
+ const { Widgets } = createWidgets({
404
+ components: {
405
+ authorBio: AuthorBio,
406
+ relatedPosts: RelatedPosts,
407
+ newsletterSignup: NewsletterSignup,
408
+ socialShare: SocialShare,
409
+ },
410
+ chrome: {
411
+ wrapper: ({ children }) => (
412
+ <aside className="blog-sidebar">{children}</aside>
413
+ ),
414
+ },
415
+ });
416
+ ```
417
+
418
+ ## Contributing
419
+
420
+ Contributions are welcome! Please feel free to submit a Pull Request.
421
+
422
+ ## License
423
+
424
+ MIT License - see LICENSE file for details.
@@ -0,0 +1,24 @@
1
+ export declare const ERROR_MESSAGES: {
2
+ readonly UNKNOWN_WIDGET: (type: string, id: string) => string;
3
+ readonly WIDGET_ERROR: (type: string, id: string) => string;
4
+ readonly WIDGET_FAILED: (type: string) => string;
5
+ readonly LOADING: "Loading widget...";
6
+ readonly UNKNOWN: "unknown";
7
+ };
8
+ export declare const DEFAULT_STYLES: {
9
+ readonly ERROR: {
10
+ readonly padding: "8px";
11
+ readonly border: "1px solid #ff6b6b";
12
+ readonly borderRadius: "4px";
13
+ readonly backgroundColor: "#ffe0e0";
14
+ readonly color: "#d63031";
15
+ };
16
+ readonly LOADING: {
17
+ readonly padding: "8px";
18
+ readonly border: "1px solid #ddd";
19
+ readonly borderRadius: "4px";
20
+ readonly backgroundColor: "#f8f9fa";
21
+ readonly color: "#666";
22
+ };
23
+ };
24
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc;oCACF,MAAM,MAAM,MAAM;kCAEpB,MAAM,MAAM,MAAM;mCAEjB,MAAM;;;CAGpB,CAAC;AAEX,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;CAejB,CAAC"}
@@ -0,0 +1,6 @@
1
+ export * from './widget.js';
2
+ export * from './widgets.js';
3
+ export * from './types.js';
4
+ export * from './constants.js';
5
+ export * from './utils.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,116 @@
1
+ import e, { Component as t, Suspense as n, createContext as r, memo as i, useContext as a, useMemo as o } from "react";
2
+ import { Fragment as s, jsx as c } from "react/jsx-runtime";
3
+ //#region src/constants.ts
4
+ var l = {
5
+ UNKNOWN_WIDGET: (e, t) => `Unknown widget type "${e}" for widget ID "${t}". Skipping render.`,
6
+ WIDGET_ERROR: (e, t) => `Widget Error: ${e} (ID: ${t})`,
7
+ WIDGET_FAILED: (e) => `Widget failed to render: ${e}`,
8
+ LOADING: "Loading widget...",
9
+ UNKNOWN: "unknown"
10
+ }, u = {
11
+ ERROR: {
12
+ padding: "8px",
13
+ border: "1px solid #ff6b6b",
14
+ borderRadius: "4px",
15
+ backgroundColor: "#ffe0e0",
16
+ color: "#d63031"
17
+ },
18
+ LOADING: {
19
+ padding: "8px",
20
+ border: "1px solid #ddd",
21
+ borderRadius: "4px",
22
+ backgroundColor: "#f8f9fa",
23
+ color: "#666"
24
+ }
25
+ };
26
+ //#endregion
27
+ //#region src/widgets.tsx
28
+ function d(e) {
29
+ return /* @__PURE__ */ c("section", { ...e });
30
+ }
31
+ var f = class extends t {
32
+ constructor(e) {
33
+ super(e), this.state = { hasError: !1 };
34
+ }
35
+ static getDerivedStateFromError(e) {
36
+ return {
37
+ hasError: !0,
38
+ error: e
39
+ };
40
+ }
41
+ componentDidCatch(e, t) {
42
+ console.error(l.WIDGET_ERROR(this.props.widgetType, this.props.widgetId), e, t);
43
+ }
44
+ render() {
45
+ return this.state.hasError ? /* @__PURE__ */ c("div", {
46
+ style: u.ERROR,
47
+ children: l.WIDGET_FAILED(this.props.widgetType)
48
+ }) : this.props.children;
49
+ }
50
+ }, p = () => /* @__PURE__ */ c("div", {
51
+ style: u.LOADING,
52
+ children: l.LOADING
53
+ });
54
+ function m(e) {
55
+ let { "data-widget-id": t, "data-widget-type": r, ...i } = e;
56
+ return /* @__PURE__ */ c("div", {
57
+ ...i,
58
+ children: /* @__PURE__ */ c(f, {
59
+ widgetId: t || l.UNKNOWN,
60
+ widgetType: r || l.UNKNOWN,
61
+ children: /* @__PURE__ */ c(n, {
62
+ fallback: /* @__PURE__ */ c(p, {}),
63
+ children: e.children
64
+ })
65
+ })
66
+ });
67
+ }
68
+ var h = e.createContext({
69
+ items: [],
70
+ ItemWrapper: (e) => /* @__PURE__ */ c("div", { ...e })
71
+ });
72
+ function g(e, t, n, r) {
73
+ let i = Object.prototype.hasOwnProperty.call(t, e.type) ? t[e.type] : void 0;
74
+ if (!i) return console.warn(l.UNKNOWN_WIDGET(e.type, e.id)), null;
75
+ let a = e.children ?? [];
76
+ return /* @__PURE__ */ c(h.Provider, {
77
+ value: {
78
+ items: a,
79
+ ItemWrapper: n
80
+ },
81
+ children: /* @__PURE__ */ c(n, {
82
+ "data-widget-id": e.id,
83
+ "data-widget-type": e.type,
84
+ children: /* @__PURE__ */ c(i, {
85
+ ...e.props,
86
+ Output: r
87
+ })
88
+ })
89
+ }, e.id);
90
+ }
91
+ //#endregion
92
+ //#region src/widget.tsx
93
+ function _(e) {
94
+ let { components: t, chrome: n, context: l } = e, u = l || r(t), f = u.Provider, p = () => a(u), _ = i(function e() {
95
+ let { items: t, ItemWrapper: n } = a(h), r = p();
96
+ return !t || t.length === 0 ? null : /* @__PURE__ */ c(s, { children: t.map((t) => g(t, r, n, e)) });
97
+ });
98
+ return {
99
+ Widgets: i(function({ items: e, components: r, chrome: i }) {
100
+ let a = i?.wrapper || n?.wrapper || d, s = i?.item || n?.item || m, l = o(() => ({
101
+ ...t,
102
+ ...r
103
+ }), [r]);
104
+ return /* @__PURE__ */ c(f, {
105
+ value: l,
106
+ children: /* @__PURE__ */ c(a, { children: e.map((e) => g(e, l, s, _)) })
107
+ });
108
+ }),
109
+ WidgetsProvider: f,
110
+ useWidgets: p,
111
+ Output: _,
112
+ defineItems: (e) => e
113
+ };
114
+ }
115
+ //#endregion
116
+ export { u as DEFAULT_STYLES, m as DefaultItem, d as DefaultWrapper, l as ERROR_MESSAGES, h as NestedWidgetsContext, _ as createWidgets, g as renderWidget };
@@ -0,0 +1,115 @@
1
+ import { ComponentProps, ComponentType, Context, ReactNode } from 'react';
2
+ /**
3
+ * The prop every widget component receives so it can render its own children.
4
+ * Injected by the renderer; it is never part of the item data.
5
+ */
6
+ export interface WidgetOutputProps {
7
+ Output: ComponentType;
8
+ }
9
+ /**
10
+ * Any widget component, for use in a generic *constraint*.
11
+ *
12
+ * `any` is load-bearing here and cannot be tightened. `ComponentType<unknown>`
13
+ * would reject a component with concrete props, because component props are
14
+ * contravariant -- a `ComponentType<{title: string}>` is not assignable to a
15
+ * `ComponentType<unknown>`. TypeScript offers no "some component, props
16
+ * unknown" type for this position.
17
+ *
18
+ * This does not weaken inference: `ComponentProps<C[K]>` below resolves against
19
+ * the concrete component that was actually passed, not against this constraint.
20
+ */
21
+ export type AnyWidgetComponent = ComponentType<any>;
22
+ /**
23
+ * A map of widget type name -> component.
24
+ *
25
+ * This is the type that drives inference for a whole widget set: pass a literal
26
+ * object to {@link createWidgets} and every item's `type` and `props` are
27
+ * checked against it.
28
+ */
29
+ export type WidgetComponentMap = Record<string, AnyWidgetComponent>;
30
+ /**
31
+ * The props a widget component accepts as *data*, i.e. everything except the
32
+ * injected {@link WidgetOutputProps.Output}.
33
+ */
34
+ export type WidgetDataProps<C extends AnyWidgetComponent> = Omit<ComponentProps<C>, keyof WidgetOutputProps>;
35
+ /**
36
+ * A single item in a widget set, discriminated on `type`.
37
+ *
38
+ * Distributing over the keys of the component map is what makes this checked:
39
+ * `type: 'news'` forces `props` to the props of the `news` component, and an
40
+ * unknown `type` is a compile error rather than a runtime `console.warn`.
41
+ */
42
+ export type WidgetItem<C extends WidgetComponentMap> = {
43
+ [K in keyof C & string]: {
44
+ /** Stable identity for this item; used as the React key. */
45
+ id: string;
46
+ /** Which component to render. Must be a key of the component map. */
47
+ type: K;
48
+ /** Props for that component, minus the injected `Output`. */
49
+ props: WidgetDataProps<C[K]>;
50
+ /** Nested items, rendered by the item's injected `Output`. */
51
+ children?: WidgetItem<C>[];
52
+ };
53
+ }[keyof C & string];
54
+ /**
55
+ * Loose item shape, for callers that build item data before a component map
56
+ * exists (a CMS payload, a fixture, a network response).
57
+ *
58
+ * Prefer {@link WidgetItem}, which is checked against the component map.
59
+ */
60
+ export interface WidgetProps<Type extends string = string, Props = object> {
61
+ id: string;
62
+ type: Type;
63
+ props: Props;
64
+ children?: WidgetProps[];
65
+ }
66
+ /**
67
+ * Type-erased view of an item, used internally by the renderer.
68
+ *
69
+ * The checked {@link WidgetItem} union is widened to this exactly once, at the
70
+ * boundary between the public props and the render loop. Spreading the
71
+ * discriminated union directly onto a component makes TypeScript give up with
72
+ * "union type that is too complex to represent" (TS2590), and the renderer
73
+ * gains nothing from the discrimination -- it looks the type up at runtime.
74
+ */
75
+ export interface RenderableWidgetItem {
76
+ id: string;
77
+ type: string;
78
+ props: Record<string, unknown>;
79
+ children?: RenderableWidgetItem[];
80
+ }
81
+ /** Chrome wrapped around the whole widget set. */
82
+ export type WidgetsWrapperComponent = ComponentType<{
83
+ children?: ReactNode;
84
+ }>;
85
+ /** Chrome wrapped around each individual widget. */
86
+ export type WidgetItemComponent = ComponentType<{
87
+ children?: ReactNode;
88
+ 'data-widget-id': string;
89
+ 'data-widget-type': string;
90
+ }>;
91
+ export interface WidgetsChrome {
92
+ wrapper?: WidgetsWrapperComponent;
93
+ item?: WidgetItemComponent;
94
+ }
95
+ /**
96
+ * Configuration for {@link createWidgets}.
97
+ */
98
+ export interface WidgetsConfig<C extends WidgetComponentMap> {
99
+ /** The component map. Its shape drives inference for the whole set. */
100
+ components: C;
101
+ chrome?: WidgetsChrome;
102
+ /** Supply your own context to share a component map across widget sets. */
103
+ context?: Context<C>;
104
+ }
105
+ /**
106
+ * Props of the `Widgets` component returned by {@link createWidgets}.
107
+ */
108
+ export interface WidgetsProps<C extends WidgetComponentMap> {
109
+ items: WidgetItem<C>[];
110
+ /** Per-instance component overrides, merged over the factory's map. */
111
+ components?: Partial<C>;
112
+ /** Per-instance chrome overrides. */
113
+ chrome?: WidgetsChrome;
114
+ }
115
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAE/E;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;;;;;;GAWG;AAEH,MAAM,MAAM,kBAAkB,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;AAEpD;;;;;;GAMG;AACH,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;AAEpE;;;GAGG;AACH,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,kBAAkB,IAAI,IAAI,CAC9D,cAAc,CAAC,CAAC,CAAC,EACjB,MAAM,iBAAiB,CACxB,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,kBAAkB,IAAI;KACpD,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,GAAG;QACvB,4DAA4D;QAC5D,EAAE,EAAE,MAAM,CAAC;QACX,qEAAqE;QACrE,IAAI,EAAE,CAAC,CAAC;QACR,6DAA6D;QAC7D,KAAK,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7B,8DAA8D;QAC9D,QAAQ,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;KAC5B;CACF,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC;AAEpB;;;;;GAKG;AACH,MAAM,WAAW,WAAW,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM,EAAE,KAAK,GAAG,MAAM;IACvE,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,KAAK,CAAC;IACb,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,QAAQ,CAAC,EAAE,oBAAoB,EAAE,CAAC;CACnC;AAED,kDAAkD;AAClD,MAAM,MAAM,uBAAuB,GAAG,aAAa,CAAC;IAAE,QAAQ,CAAC,EAAE,SAAS,CAAA;CAAE,CAAC,CAAC;AAE9E,oDAAoD;AACpD,MAAM,MAAM,mBAAmB,GAAG,aAAa,CAAC;IAC9C,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,kBAAkB,EAAE,MAAM,CAAC;CAC5B,CAAC,CAAC;AAEH,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,uBAAuB,CAAC;IAClC,IAAI,CAAC,EAAE,mBAAmB,CAAC;CAC5B;AAED;;GAEG;AACH,MAAM,WAAW,aAAa,CAAC,CAAC,SAAS,kBAAkB;IACzD,uEAAuE;IACvE,UAAU,EAAE,CAAC,CAAC;IACd,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,kBAAkB;IACxD,KAAK,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;IACvB,uEAAuE;IACvE,UAAU,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACxB,qCAAqC;IACrC,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB"}
@@ -0,0 +1,17 @@
1
+ import { default as React } from 'react';
2
+ import { AnyWidgetComponent, RenderableWidgetItem, WidgetItemComponent } from './types.js';
3
+ /**
4
+ * Context carrying the current widget's children down to the injected `Output`
5
+ * component, together with the chrome resolved for this render.
6
+ *
7
+ * Passing children through context (rather than closing over them in a
8
+ * freshly-created component) is what keeps `Output` a single stable component
9
+ * type, and what lets nesting recurse to arbitrary depth.
10
+ */
11
+ export interface NestedWidgets {
12
+ items: RenderableWidgetItem[];
13
+ ItemWrapper: WidgetItemComponent;
14
+ }
15
+ export declare const NestedWidgetsContext: React.Context<NestedWidgets>;
16
+ export declare function renderWidget(item: RenderableWidgetItem, components: Record<string, AnyWidgetComponent>, ItemWrapper: WidgetItemComponent, Output: React.ComponentType): React.JSX.Element | null;
17
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EACV,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACpB,MAAM,YAAY,CAAC;AAEpB;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAC9B,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAMD,eAAO,MAAM,oBAAoB,8BAG/B,CAAC;AAEH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,oBAAoB,EAC1B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,EAC9C,WAAW,EAAE,mBAAmB,EAChC,MAAM,EAAE,KAAK,CAAC,aAAa,4BA+B5B"}
@@ -0,0 +1,27 @@
1
+ import { WidgetComponentMap, WidgetItem, WidgetsConfig, WidgetsProps } from './types.js';
2
+ /**
3
+ * Builds a widget set from a component map.
4
+ *
5
+ * The map drives inference: each item's `type` must be a key of it, and that
6
+ * item's `props` must match the corresponding component's props.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * const { Widgets } = createWidgets({
11
+ * components: { news: NewsTeaser, profile: UserSidebar },
12
+ * });
13
+ *
14
+ * <Widgets items={[
15
+ * { id: '1', type: 'news', props: { title: 'Hello' } },
16
+ * { id: '2', type: 'nope', props: {} }, // ← compile error: unknown type
17
+ * ]} />
18
+ * ```
19
+ */
20
+ export declare function createWidgets<const C extends WidgetComponentMap>(config: WidgetsConfig<C>): {
21
+ Widgets: import('react').MemoExoticComponent<({ items, components: instanceComponents, chrome, }: WidgetsProps<C>) => import("react").JSX.Element>;
22
+ WidgetsProvider: import('react').Provider<C>;
23
+ useWidgets: () => C;
24
+ Output: import('react').MemoExoticComponent<() => import("react").JSX.Element | null>;
25
+ defineItems: (items: WidgetItem<C>[]) => WidgetItem<C>[];
26
+ };
27
+ //# sourceMappingURL=widget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"widget.d.ts","sourceRoot":"","sources":["../src/widget.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAEV,kBAAkB,EAClB,UAAU,EACV,aAAa,EACb,YAAY,EACb,MAAM,YAAY,CAAC;AAIpB;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,KAAK,CAAC,CAAC,SAAS,kBAAkB,EAC9D,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;sGA2CrB,YAAY,CAAC,CAAC,CAAC;;;;yBAsCU,UAAU,CAAC,CAAC,CAAC,EAAE,KAAG,UAAU,CAAC,CAAC,CAAC,EAAE;EAG9D"}
@@ -0,0 +1,7 @@
1
+ import { default as React, HTMLProps } from 'react';
2
+ export declare function DefaultWrapper(props: HTMLProps<HTMLDivElement>): React.JSX.Element;
3
+ export declare function DefaultItem(props: HTMLProps<HTMLDivElement> & {
4
+ 'data-widget-id'?: string;
5
+ 'data-widget-type'?: string;
6
+ }): React.JSX.Element;
7
+ //# sourceMappingURL=widgets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"widgets.d.ts","sourceRoot":"","sources":["../src/widgets.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,SAAS,EAAkC,MAAM,OAAO,CAAC;AAGzE,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,CAAC,cAAc,CAAC,qBAE9D;AA8CD,wBAAgB,WAAW,CACzB,KAAK,EAAE,SAAS,CAAC,cAAc,CAAC,GAAG;IACjC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B,qBAoBF"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@evanion/react-widget",
3
+ "version": "0.1.0",
4
+ "description": "Render dynamic, type-safe React widget regions from structured data. Built for CMS-driven layouts, dashboards and configurable sidebars.",
5
+ "keywords": [
6
+ "react",
7
+ "widget",
8
+ "cms",
9
+ "dynamic",
10
+ "layout",
11
+ "components",
12
+ "registry",
13
+ "typescript"
14
+ ],
15
+ "homepage": "https://github.com/Evanion/libraries/tree/main/libs/widget#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/Evanion/libraries/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/Evanion/libraries.git",
22
+ "directory": "libs/widget"
23
+ },
24
+ "license": "MIT",
25
+ "author": "Mikael Pettersson",
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "main": "./dist/index.js",
32
+ "module": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ "./package.json": "./package.json",
36
+ ".": {
37
+ "@evanion/source": "./src/index.ts",
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.js",
40
+ "default": "./dist/index.js"
41
+ }
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "!dist/**/*.tsbuildinfo",
46
+ "src",
47
+ "!src/**/*.test.*",
48
+ "!src/**/*.spec.*",
49
+ "!src/**/*.test-d.*",
50
+ "!src/test-setup.ts",
51
+ "README.md",
52
+ "LICENSE",
53
+ "CHANGELOG.md"
54
+ ],
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "provenance": true
58
+ },
59
+ "peerDependencies": {
60
+ "react": "^18.0.0 || ^19.0.0"
61
+ }
62
+ }
@@ -0,0 +1,26 @@
1
+ export const ERROR_MESSAGES = {
2
+ UNKNOWN_WIDGET: (type: string, id: string) =>
3
+ `Unknown widget type "${type}" for widget ID "${id}". Skipping render.`,
4
+ WIDGET_ERROR: (type: string, id: string) =>
5
+ `Widget Error: ${type} (ID: ${id})`,
6
+ WIDGET_FAILED: (type: string) => `Widget failed to render: ${type}`,
7
+ LOADING: 'Loading widget...',
8
+ UNKNOWN: 'unknown',
9
+ } as const;
10
+
11
+ export const DEFAULT_STYLES = {
12
+ ERROR: {
13
+ padding: '8px',
14
+ border: '1px solid #ff6b6b',
15
+ borderRadius: '4px',
16
+ backgroundColor: '#ffe0e0',
17
+ color: '#d63031',
18
+ },
19
+ LOADING: {
20
+ padding: '8px',
21
+ border: '1px solid #ddd',
22
+ borderRadius: '4px',
23
+ backgroundColor: '#f8f9fa',
24
+ color: '#666',
25
+ },
26
+ } as const;
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './widget.js';
2
+ export * from './widgets.js';
3
+ export * from './types.js';
4
+ export * from './constants.js';
5
+ export * from './utils.js';
package/src/types.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { ComponentProps, ComponentType, Context, ReactNode } from 'react';
2
+
3
+ /**
4
+ * The prop every widget component receives so it can render its own children.
5
+ * Injected by the renderer; it is never part of the item data.
6
+ */
7
+ export interface WidgetOutputProps {
8
+ Output: ComponentType;
9
+ }
10
+
11
+ /**
12
+ * Any widget component, for use in a generic *constraint*.
13
+ *
14
+ * `any` is load-bearing here and cannot be tightened. `ComponentType<unknown>`
15
+ * would reject a component with concrete props, because component props are
16
+ * contravariant -- a `ComponentType<{title: string}>` is not assignable to a
17
+ * `ComponentType<unknown>`. TypeScript offers no "some component, props
18
+ * unknown" type for this position.
19
+ *
20
+ * This does not weaken inference: `ComponentProps<C[K]>` below resolves against
21
+ * the concrete component that was actually passed, not against this constraint.
22
+ */
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ export type AnyWidgetComponent = ComponentType<any>;
25
+
26
+ /**
27
+ * A map of widget type name -> component.
28
+ *
29
+ * This is the type that drives inference for a whole widget set: pass a literal
30
+ * object to {@link createWidgets} and every item's `type` and `props` are
31
+ * checked against it.
32
+ */
33
+ export type WidgetComponentMap = Record<string, AnyWidgetComponent>;
34
+
35
+ /**
36
+ * The props a widget component accepts as *data*, i.e. everything except the
37
+ * injected {@link WidgetOutputProps.Output}.
38
+ */
39
+ export type WidgetDataProps<C extends AnyWidgetComponent> = Omit<
40
+ ComponentProps<C>,
41
+ keyof WidgetOutputProps
42
+ >;
43
+
44
+ /**
45
+ * A single item in a widget set, discriminated on `type`.
46
+ *
47
+ * Distributing over the keys of the component map is what makes this checked:
48
+ * `type: 'news'` forces `props` to the props of the `news` component, and an
49
+ * unknown `type` is a compile error rather than a runtime `console.warn`.
50
+ */
51
+ export type WidgetItem<C extends WidgetComponentMap> = {
52
+ [K in keyof C & string]: {
53
+ /** Stable identity for this item; used as the React key. */
54
+ id: string;
55
+ /** Which component to render. Must be a key of the component map. */
56
+ type: K;
57
+ /** Props for that component, minus the injected `Output`. */
58
+ props: WidgetDataProps<C[K]>;
59
+ /** Nested items, rendered by the item's injected `Output`. */
60
+ children?: WidgetItem<C>[];
61
+ };
62
+ }[keyof C & string];
63
+
64
+ /**
65
+ * Loose item shape, for callers that build item data before a component map
66
+ * exists (a CMS payload, a fixture, a network response).
67
+ *
68
+ * Prefer {@link WidgetItem}, which is checked against the component map.
69
+ */
70
+ export interface WidgetProps<Type extends string = string, Props = object> {
71
+ id: string;
72
+ type: Type;
73
+ props: Props;
74
+ children?: WidgetProps[];
75
+ }
76
+
77
+ /**
78
+ * Type-erased view of an item, used internally by the renderer.
79
+ *
80
+ * The checked {@link WidgetItem} union is widened to this exactly once, at the
81
+ * boundary between the public props and the render loop. Spreading the
82
+ * discriminated union directly onto a component makes TypeScript give up with
83
+ * "union type that is too complex to represent" (TS2590), and the renderer
84
+ * gains nothing from the discrimination -- it looks the type up at runtime.
85
+ */
86
+ export interface RenderableWidgetItem {
87
+ id: string;
88
+ type: string;
89
+ props: Record<string, unknown>;
90
+ children?: RenderableWidgetItem[];
91
+ }
92
+
93
+ /** Chrome wrapped around the whole widget set. */
94
+ export type WidgetsWrapperComponent = ComponentType<{ children?: ReactNode }>;
95
+
96
+ /** Chrome wrapped around each individual widget. */
97
+ export type WidgetItemComponent = ComponentType<{
98
+ children?: ReactNode;
99
+ 'data-widget-id': string;
100
+ 'data-widget-type': string;
101
+ }>;
102
+
103
+ export interface WidgetsChrome {
104
+ wrapper?: WidgetsWrapperComponent;
105
+ item?: WidgetItemComponent;
106
+ }
107
+
108
+ /**
109
+ * Configuration for {@link createWidgets}.
110
+ */
111
+ export interface WidgetsConfig<C extends WidgetComponentMap> {
112
+ /** The component map. Its shape drives inference for the whole set. */
113
+ components: C;
114
+ chrome?: WidgetsChrome;
115
+ /** Supply your own context to share a component map across widget sets. */
116
+ context?: Context<C>;
117
+ }
118
+
119
+ /**
120
+ * Props of the `Widgets` component returned by {@link createWidgets}.
121
+ */
122
+ export interface WidgetsProps<C extends WidgetComponentMap> {
123
+ items: WidgetItem<C>[];
124
+ /** Per-instance component overrides, merged over the factory's map. */
125
+ components?: Partial<C>;
126
+ /** Per-instance chrome overrides. */
127
+ chrome?: WidgetsChrome;
128
+ }
package/src/utils.tsx ADDED
@@ -0,0 +1,66 @@
1
+ import React from 'react';
2
+ import { ERROR_MESSAGES } from './constants.js';
3
+ import type {
4
+ AnyWidgetComponent,
5
+ RenderableWidgetItem,
6
+ WidgetItemComponent,
7
+ } from './types.js';
8
+
9
+ /**
10
+ * Context carrying the current widget's children down to the injected `Output`
11
+ * component, together with the chrome resolved for this render.
12
+ *
13
+ * Passing children through context (rather than closing over them in a
14
+ * freshly-created component) is what keeps `Output` a single stable component
15
+ * type, and what lets nesting recurse to arbitrary depth.
16
+ */
17
+ export interface NestedWidgets {
18
+ items: RenderableWidgetItem[];
19
+ ItemWrapper: WidgetItemComponent;
20
+ }
21
+
22
+ const DefaultNestedItemWrapper: WidgetItemComponent = (props) => (
23
+ <div {...props} />
24
+ );
25
+
26
+ export const NestedWidgetsContext = React.createContext<NestedWidgets>({
27
+ items: [],
28
+ ItemWrapper: DefaultNestedItemWrapper,
29
+ });
30
+
31
+ export function renderWidget(
32
+ item: RenderableWidgetItem,
33
+ components: Record<string, AnyWidgetComponent>,
34
+ ItemWrapper: WidgetItemComponent,
35
+ Output: React.ComponentType,
36
+ ) {
37
+ // `in` walks the prototype chain, so a CMS-supplied type of "constructor",
38
+ // "toString" or "__proto__" would pass this guard and hand React something
39
+ // off Object.prototype. Items are explicitly untrusted input.
40
+ //
41
+ // The truthiness check is not redundant: an own key can still hold undefined,
42
+ // and noUncheckedIndexedAccess makes that possibility explicit.
43
+ const Component = Object.prototype.hasOwnProperty.call(components, item.type)
44
+ ? components[item.type]
45
+ : undefined;
46
+
47
+ if (!Component) {
48
+ console.warn(ERROR_MESSAGES.UNKNOWN_WIDGET(item.type, item.id));
49
+ return null;
50
+ }
51
+
52
+ const children = item.children ?? [];
53
+
54
+ return (
55
+ // Each widget provides its own children, so a nested `Output` renders that
56
+ // widget's children rather than its parent's -- at any depth.
57
+ <NestedWidgetsContext.Provider
58
+ key={item.id}
59
+ value={{ items: children, ItemWrapper }}
60
+ >
61
+ <ItemWrapper data-widget-id={item.id} data-widget-type={item.type}>
62
+ <Component {...item.props} Output={Output} />
63
+ </ItemWrapper>
64
+ </NestedWidgetsContext.Provider>
65
+ );
66
+ }
package/src/widget.tsx ADDED
@@ -0,0 +1,115 @@
1
+ import { createContext, useContext, useMemo, memo } from 'react';
2
+ import type {
3
+ RenderableWidgetItem,
4
+ WidgetComponentMap,
5
+ WidgetItem,
6
+ WidgetsConfig,
7
+ WidgetsProps,
8
+ } from './types.js';
9
+ import { DefaultItem, DefaultWrapper } from './widgets.js';
10
+ import { renderWidget, NestedWidgetsContext } from './utils.js';
11
+
12
+ /**
13
+ * Builds a widget set from a component map.
14
+ *
15
+ * The map drives inference: each item's `type` must be a key of it, and that
16
+ * item's `props` must match the corresponding component's props.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * const { Widgets } = createWidgets({
21
+ * components: { news: NewsTeaser, profile: UserSidebar },
22
+ * });
23
+ *
24
+ * <Widgets items={[
25
+ * { id: '1', type: 'news', props: { title: 'Hello' } },
26
+ * { id: '2', type: 'nope', props: {} }, // ← compile error: unknown type
27
+ * ]} />
28
+ * ```
29
+ */
30
+ export function createWidgets<const C extends WidgetComponentMap>(
31
+ config: WidgetsConfig<C>,
32
+ ) {
33
+ const {
34
+ components: defaultComponents,
35
+ chrome: defaultChrome,
36
+ context,
37
+ } = config;
38
+
39
+ const WidgetsContext = context || createContext<C>(defaultComponents);
40
+
41
+ const WidgetsProvider = WidgetsContext.Provider;
42
+ const useWidgets = () => useContext(WidgetsContext);
43
+
44
+ /**
45
+ * Renders the children of whichever widget is currently rendering.
46
+ *
47
+ * Defined once per `createWidgets` call, so its component type is stable for
48
+ * the lifetime of the factory. Previously a new component was built on every
49
+ * render, and because React compares element types by identity that
50
+ * unmounted and remounted every nested subtree on each parent render --
51
+ * discarding child state, effects and focus.
52
+ */
53
+ const Output = memo(function Output() {
54
+ const { items, ItemWrapper } = useContext(NestedWidgetsContext);
55
+ const components = useWidgets();
56
+
57
+ if (!items || items.length === 0) {
58
+ return null;
59
+ }
60
+
61
+ return (
62
+ <>
63
+ {items.map((item) =>
64
+ renderWidget(item, components, ItemWrapper, Output),
65
+ )}
66
+ </>
67
+ );
68
+ });
69
+
70
+ const Widgets = memo(function Widgets({
71
+ items,
72
+ components: instanceComponents,
73
+ chrome,
74
+ }: WidgetsProps<C>) {
75
+ const Wrapper = chrome?.wrapper || defaultChrome?.wrapper || DefaultWrapper;
76
+ const ItemWrapper = chrome?.item || defaultChrome?.item || DefaultItem;
77
+ const components = useMemo(
78
+ () => ({ ...defaultComponents, ...instanceComponents }),
79
+ [instanceComponents],
80
+ );
81
+
82
+ return (
83
+ <WidgetsProvider value={components}>
84
+ <Wrapper>
85
+ {/* The single, documented widening from the checked WidgetItem<C>
86
+ union to the renderer's erased view. See RenderableWidgetItem. */}
87
+ {(items as unknown as RenderableWidgetItem[]).map((item) =>
88
+ renderWidget(item, components, ItemWrapper, Output),
89
+ )}
90
+ </Wrapper>
91
+ </WidgetsProvider>
92
+ );
93
+ });
94
+
95
+ /**
96
+ * Identity function that supplies the contextual type for an item array.
97
+ *
98
+ * A bare `const items = [{ type: 'news', ... }]` infers `type: string`, which
99
+ * will not narrow to the component map's keys, so the check is lost. Passing
100
+ * the array through here gives TypeScript the contextual type it needs:
101
+ *
102
+ * ```ts
103
+ * const items = defineItems([
104
+ * { id: '1', type: 'news', props: { title: 'Hello' } },
105
+ * { id: '2', type: 'nope', props: {} }, // ← compile error
106
+ * ]);
107
+ * ```
108
+ *
109
+ * Not needed when the array is written inline in JSX -- that is already
110
+ * contextually typed. `satisfies WidgetItem<typeof components>[]` works too.
111
+ */
112
+ const defineItems = (items: WidgetItem<C>[]): WidgetItem<C>[] => items;
113
+
114
+ return { Widgets, WidgetsProvider, useWidgets, Output, defineItems };
115
+ }
@@ -0,0 +1,76 @@
1
+ import React, { HTMLProps, Suspense, Component, ReactNode } from 'react';
2
+ import { ERROR_MESSAGES, DEFAULT_STYLES } from './constants.js';
3
+
4
+ export function DefaultWrapper(props: HTMLProps<HTMLDivElement>) {
5
+ return <section {...props} />;
6
+ }
7
+
8
+ // Error boundary for individual widgets
9
+ class WidgetErrorBoundary extends Component<
10
+ { children: ReactNode; widgetId: string; widgetType: string },
11
+ { hasError: boolean; error?: Error }
12
+ > {
13
+ constructor(props: {
14
+ children: ReactNode;
15
+ widgetId: string;
16
+ widgetType: string;
17
+ }) {
18
+ super(props);
19
+ this.state = { hasError: false };
20
+ }
21
+
22
+ static getDerivedStateFromError(error: Error) {
23
+ return { hasError: true, error };
24
+ }
25
+
26
+ override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
27
+ console.error(
28
+ ERROR_MESSAGES.WIDGET_ERROR(this.props.widgetType, this.props.widgetId),
29
+ error,
30
+ errorInfo,
31
+ );
32
+ }
33
+
34
+ override render() {
35
+ if (this.state.hasError) {
36
+ return (
37
+ <div style={DEFAULT_STYLES.ERROR}>
38
+ {ERROR_MESSAGES.WIDGET_FAILED(this.props.widgetType)}
39
+ </div>
40
+ );
41
+ }
42
+
43
+ return this.props.children;
44
+ }
45
+ }
46
+
47
+ // Default loading fallback
48
+ const DefaultLoadingFallback = () => (
49
+ <div style={DEFAULT_STYLES.LOADING}>{ERROR_MESSAGES.LOADING}</div>
50
+ );
51
+
52
+ export function DefaultItem(
53
+ props: HTMLProps<HTMLDivElement> & {
54
+ 'data-widget-id'?: string;
55
+ 'data-widget-type'?: string;
56
+ },
57
+ ) {
58
+ const {
59
+ 'data-widget-id': widgetId,
60
+ 'data-widget-type': widgetType,
61
+ ...restProps
62
+ } = props;
63
+
64
+ return (
65
+ <div {...restProps}>
66
+ <WidgetErrorBoundary
67
+ widgetId={widgetId || ERROR_MESSAGES.UNKNOWN}
68
+ widgetType={widgetType || ERROR_MESSAGES.UNKNOWN}
69
+ >
70
+ <Suspense fallback={<DefaultLoadingFallback />}>
71
+ {props.children}
72
+ </Suspense>
73
+ </WidgetErrorBoundary>
74
+ </div>
75
+ );
76
+ }