@evanion/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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 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,171 @@
1
+ [![npm version](https://img.shields.io/npm/v/@evanion/widget)](https://www.npmjs.com/package/@evanion/widget)
2
+ [![npm downloads](https://img.shields.io/npm/dm/@evanion/widget)](https://www.npmjs.com/package/@evanion/widget)
3
+ [![CI](https://github.com/Evanion/libraries/actions/workflows/ci.yml/badge.svg)](https://github.com/Evanion/libraries/actions/workflows/ci.yml)
4
+
5
+ # Widget
6
+
7
+ The framework-free half of a widget region: the item shape, the registry, and
8
+ the validator. It renders nothing. A renderer is one package per framework, and
9
+ each of them depends on this one:
10
+
11
+ | Package | Renders |
12
+ | ----------------------- | ---------------------------- |
13
+ | `@evanion/react-widget` | React, Server Components too |
14
+ | `@evanion/astro-widget` | Astro, at build time |
15
+
16
+ Install a renderer, not this. Every type below is re-exported from each of them,
17
+ so a consumer who never names this package never installs it by hand.
18
+
19
+ ## What a widget region is
20
+
21
+ A page described as data: a list of items, each naming a component by `type` and
22
+ carrying the props it takes. The renderer resolves the type against a registry
23
+ and renders it.
24
+
25
+ ```ts
26
+ import { defineWidgets, validateItems } from '@evanion/widget';
27
+ import type { AnyWidgetItem } from '@evanion/widget';
28
+
29
+ const registry = defineWidgets({ hero: Hero, prose: Prose });
30
+
31
+ const items: AnyWidgetItem[] = [
32
+ {
33
+ id: 'top',
34
+ type: 'hero',
35
+ props: { heading: 'Hello' },
36
+ meta: { width: 'full' },
37
+ },
38
+ { id: 'about', type: 'prose', props: { body: '…' } },
39
+ ];
40
+
41
+ validateItems(items, registry, { hero: ['heading'] }); // -> []
42
+ ```
43
+
44
+ The same array renders through every adapter and produces the same sequence of
45
+ widgets.
46
+
47
+ ## Why this package exists
48
+
49
+ Two renderers held two copies of these rules under two vocabularies, and the
50
+ same prototype-chain bug had to be fixed in both. A third renderer would have
51
+ been a third copy. The rules are the part that does not differ between
52
+ frameworks; resolving a type to a component and putting children somewhere is
53
+ the part that does.
54
+
55
+ Nothing here imports a framework, so it also runs where no renderer does: a
56
+ webhook that checks a CMS payload on its way in, a build script, a test.
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ npm install @evanion/widget
62
+ ```
63
+
64
+ Or with yarn:
65
+
66
+ ```bash
67
+ yarn add @evanion/widget
68
+ ```
69
+
70
+ Or with pnpm:
71
+
72
+ ```bash
73
+ pnpm add @evanion/widget
74
+ ```
75
+
76
+ ## The item
77
+
78
+ ```ts
79
+ interface AnyWidgetItem<Type extends string = string, Props = object> {
80
+ id: string;
81
+ type: Type;
82
+ props: Props;
83
+ meta?: Record<string, unknown>;
84
+ children?: AnyWidgetItem[];
85
+ }
86
+ ```
87
+
88
+ `id` is required. It is the key a renderer lists the item under, the identity in
89
+ a warning about a stale type, and what the duplicate-sibling check is about. A
90
+ CMS with no per-section id has to supply one; an index-derived value is fine as
91
+ long as it is stable across renders.
92
+
93
+ `props` is a named field rather than "every key the renderer does not claim".
94
+ The renderer's own fields would otherwise be reserved words in the CMS's
95
+ vocabulary, and adding one later would take a prop away from every payload
96
+ already written.
97
+
98
+ It is required, and `validateItems` reports an item without it. A widget's data
99
+ lives under that key and nowhere else, so an item missing it is one whose props
100
+ the payload put somewhere no renderer reads — which is what a payload written
101
+ against a flat item shape looks like, and what a renderer would draw as an empty
102
+ widget with nothing logged.
103
+
104
+ `meta` is placement: which column, what span, whether a rule sits above it. It
105
+ goes to the region's chrome and never into the widget's own props, because where
106
+ a widget sits is not something the widget should know.
107
+
108
+ `children` is nested items. What a renderer does with them is the runtime's
109
+ business — React renders them as the component's `children`, while an Astro
110
+ component receives child content through `<slot />` and is handed them as data
111
+ to open its own region over.
112
+
113
+ ## `defineWidgets(registry)`
114
+
115
+ Returns the registry unchanged, typed as the literal object passed in.
116
+
117
+ ```ts
118
+ const registry = defineWidgets({ hero: Hero, text: Text });
119
+ // ^? { hero: typeof Hero; text: typeof Text }
120
+ ```
121
+
122
+ Annotating the same object as `WidgetRegistry` would widen its keys to `string`,
123
+ and the key union is what an editor completes on and what a `required` map is
124
+ checked against.
125
+
126
+ ## `validateItems(items, known, required?)`
127
+
128
+ Checks a list against the set of known types and returns `WidgetProblem[]`.
129
+ Problems rather than an exception, and accumulated rather than short-circuited,
130
+ so a caller can print all of them at once.
131
+
132
+ ```ts
133
+ validateItems([{ id: 'a', type: 'nope', props: {} }], ['news']);
134
+ // -> [{ index: 0, id: 'a', type: 'nope', message: 'unknown widget type' }]
135
+ ```
136
+
137
+ `known` is a registry or a plain list of names, so a CI script can validate a
138
+ payload without importing components it will never render.
139
+
140
+ `required` maps a type to the props that must be present and non-blank, where
141
+ blank means `undefined`, `null` or whitespace only — which is what a CMS text
142
+ field that was opened and left empty arrives as.
143
+
144
+ ```ts
145
+ validateItems([{ id: 'a', type: 'hero', props: {} }], registry, {
146
+ hero: ['heading'],
147
+ });
148
+ // -> [{ index: 0, id: 'a', type: 'hero', message: 'missing field heading' }]
149
+ ```
150
+
151
+ No renderer calls this. Each one stays defensive — an item it cannot render is
152
+ skipped and warned about — and validation is the loud gate you run at ingestion
153
+ or build time.
154
+
155
+ A type is looked up as an own key of the registry, so a CMS item typed
156
+ `constructor`, `toString` or `__proto__` is unknown rather than resolving to
157
+ something off `Object.prototype`.
158
+
159
+ ## Exports
160
+
161
+ `defineWidgets`, `validateItems`, `warnOnce`, `resetWarnings`,
162
+ `ERROR_MESSAGES`, `VALIDATION_MESSAGES`, and the types `AnyWidgetItem`,
163
+ `WidgetRegistry`, `WidgetMeta`, `WidgetProblem`, `KnownWidgetTypes`.
164
+
165
+ `warnOnce` and `resetWarnings` are there for the adapters, which are separate
166
+ packages and cannot reach a module this one does not publish. A consumer has no
167
+ reason to call either.
168
+
169
+ ## License
170
+
171
+ MIT
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Messages an adapter's renderer passes to {@link warnOnce}.
3
+ *
4
+ * Each one names the offending item's `id` and `type`, which is what lets
5
+ * `warnOnce` key on the message text and still report a second bad item
6
+ * separately.
7
+ *
8
+ * They live here rather than in each adapter so that a region rendered through
9
+ * React and the same region rendered through Astro report a stale CMS type in
10
+ * the same words.
11
+ */
12
+ export declare const ERROR_MESSAGES: {
13
+ readonly UNKNOWN_WIDGET: (type: string, id: string) => string;
14
+ readonly UNKNOWN: "unknown";
15
+ readonly MALFORMED_ITEMS: "Malformed `items` prop: expected an array of widget items. Skipping render.";
16
+ readonly MALFORMED_ITEM: (id: string | undefined, type: unknown) => string;
17
+ readonly MALFORMED_CHILDREN: (id: string) => string;
18
+ };
19
+ /**
20
+ * Messages reported by {@link validateItems}.
21
+ *
22
+ * Exported so a caller can group or translate problems without matching on
23
+ * prose, and so the tests assert against the same strings the library emits.
24
+ */
25
+ export declare const VALIDATION_MESSAGES: {
26
+ readonly NOT_A_LIST: "items is not a list";
27
+ readonly NOT_AN_OBJECT: "item is not an object";
28
+ readonly INVALID_ID: "item id is not a string";
29
+ readonly INVALID_TYPE: "item type is not a string";
30
+ readonly UNKNOWN_TYPE: "unknown widget type";
31
+ readonly INVALID_PROPS: "props is not an object";
32
+ readonly INVALID_CHILDREN: "children is not a list";
33
+ readonly DUPLICATE_ID: "duplicate sibling id";
34
+ /**
35
+ * A field the caller's `required` map demands is absent or blank.
36
+ *
37
+ * A function rather than a constant because the field name is the whole of
38
+ * the report: a CMS editor reads "missing field heading" and knows which box
39
+ * to fill.
40
+ */
41
+ readonly MISSING_FIELD: (field: string) => string;
42
+ };
43
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,cAAc;oCACF,MAAM,MAAM,MAAM;;;kCAKpB,MAAM,GAAG,SAAS,QAAQ,OAAO;sCAE7B,MAAM;CAEvB,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;IAS9B;;;;;;OAMG;oCACoB,MAAM;CACrB,CAAC"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Messages an adapter's renderer passes to {@link warnOnce}.
3
+ *
4
+ * Each one names the offending item's `id` and `type`, which is what lets
5
+ * `warnOnce` key on the message text and still report a second bad item
6
+ * separately.
7
+ *
8
+ * They live here rather than in each adapter so that a region rendered through
9
+ * React and the same region rendered through Astro report a stale CMS type in
10
+ * the same words.
11
+ */
12
+ export const ERROR_MESSAGES = {
13
+ UNKNOWN_WIDGET: (type, id) => `Unknown widget type "${type}" for widget ID "${id}". Skipping render.`,
14
+ UNKNOWN: 'unknown',
15
+ MALFORMED_ITEMS: 'Malformed `items` prop: expected an array of widget items. Skipping render.',
16
+ MALFORMED_ITEM: (id, type) => `Malformed widget item (id="${id ?? 'unknown'}", type="${typeof type === 'string' ? type : 'unknown'}"). Skipping render.`,
17
+ MALFORMED_CHILDREN: (id) => `Malformed \`children\` on widget item (id="${id}"): expected an array. Rendering the widget without them.`,
18
+ };
19
+ /**
20
+ * Messages reported by {@link validateItems}.
21
+ *
22
+ * Exported so a caller can group or translate problems without matching on
23
+ * prose, and so the tests assert against the same strings the library emits.
24
+ */
25
+ export const VALIDATION_MESSAGES = {
26
+ NOT_A_LIST: 'items is not a list',
27
+ NOT_AN_OBJECT: 'item is not an object',
28
+ INVALID_ID: 'item id is not a string',
29
+ INVALID_TYPE: 'item type is not a string',
30
+ UNKNOWN_TYPE: 'unknown widget type',
31
+ INVALID_PROPS: 'props is not an object',
32
+ INVALID_CHILDREN: 'children is not a list',
33
+ DUPLICATE_ID: 'duplicate sibling id',
34
+ /**
35
+ * A field the caller's `required` map demands is absent or blank.
36
+ *
37
+ * A function rather than a constant because the field name is the whole of
38
+ * the report: a CMS editor reads "missing field heading" and knows which box
39
+ * to fill.
40
+ */
41
+ MISSING_FIELD: (field) => `missing field ${field}`,
42
+ };
@@ -0,0 +1,23 @@
1
+ import type { WidgetRegistry } from './types.js';
2
+ /**
3
+ * Returns the registry unchanged, typed as the literal object that was passed.
4
+ *
5
+ * The generic parameter is the whole point: annotating the same object as
6
+ * `WidgetRegistry` widens its keys to `string`, and the key union is what an
7
+ * editor completes on and what a caller narrows a `required` map against.
8
+ *
9
+ * In the core rather than in an adapter because every adapter needs it, and
10
+ * because a React consumer assembling a registry before handing it to
11
+ * `createWidgets` wants it too.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import Hero from './Hero.astro';
16
+ * import Text from './Text.astro';
17
+ *
18
+ * const registry = defineWidgets({ hero: Hero, text: Text });
19
+ * // ^? { hero: AstroComponentFactory; text: AstroComponentFactory }
20
+ * ```
21
+ */
22
+ export declare function defineWidgets<R extends WidgetRegistry>(registry: R): R;
23
+ //# sourceMappingURL=define-widgets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define-widgets.d.ts","sourceRoot":"","sources":["../src/define-widgets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,cAAc,EAAE,QAAQ,EAAE,CAAC,GAAG,CAAC,CAEtE"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Returns the registry unchanged, typed as the literal object that was passed.
3
+ *
4
+ * The generic parameter is the whole point: annotating the same object as
5
+ * `WidgetRegistry` widens its keys to `string`, and the key union is what an
6
+ * editor completes on and what a caller narrows a `required` map against.
7
+ *
8
+ * In the core rather than in an adapter because every adapter needs it, and
9
+ * because a React consumer assembling a registry before handing it to
10
+ * `createWidgets` wants it too.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import Hero from './Hero.astro';
15
+ * import Text from './Text.astro';
16
+ *
17
+ * const registry = defineWidgets({ hero: Hero, text: Text });
18
+ * // ^? { hero: AstroComponentFactory; text: AstroComponentFactory }
19
+ * ```
20
+ */
21
+ export function defineWidgets(registry) {
22
+ return registry;
23
+ }
@@ -0,0 +1,6 @@
1
+ export * from './types.js';
2
+ export * from './constants.js';
3
+ export * from './define-widgets.js';
4
+ export * from './validate-items.js';
5
+ export * from './warn.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,WAAW,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ // The framework-free half of a widget region.
2
+ //
3
+ // Nothing here imports a framework, so this package is usable from a webhook
4
+ // handler, a Nest service or a CI script that validates a CMS payload before
5
+ // anything renders it. scripts/verify-packaging.mjs greps the packed `dist/`
6
+ // for a framework import, because that promise breaks silently: the build
7
+ // succeeds and every test passes.
8
+ //
9
+ // The renderers live one package per framework -- `@evanion/react-widget`,
10
+ // `@evanion/astro-widget` -- and each re-exports what its consumers need from
11
+ // here, so a consumer who never names this package never installs it by hand.
12
+ export * from './types.js';
13
+ export * from './constants.js';
14
+ export * from './define-widgets.js';
15
+ export * from './validate-items.js';
16
+ export * from './warn.js';
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Maps a widget type name to whatever the adapter resolves it to.
3
+ *
4
+ * `T` is the adapter's component type. `@evanion/react-widget` instantiates it
5
+ * as a `ComponentType`, which is what drives its compile-time prop check.
6
+ * `@evanion/astro-widget` leaves it at the default: an `.astro` module's
7
+ * default export is an `AstroComponentFactory` carrying no prop types at all --
8
+ * the props of a `.astro` file live in its frontmatter `Props` interface and
9
+ * are not reachable from the factory's type -- so there is nothing there to
10
+ * infer from and {@link validateItems} covers that ground at build time.
11
+ */
12
+ export type WidgetRegistry<T = unknown> = Record<string, T>;
13
+ /**
14
+ * The `meta` vocabulary of a widget set whose chrome declares none.
15
+ *
16
+ * Any object, so an item may carry whatever placement data it likes and a
17
+ * chrome that reads `meta` narrows it by hand.
18
+ */
19
+ export type WidgetMeta = Record<string, unknown>;
20
+ /**
21
+ * Loose item shape, for data built before a registry exists: a CMS payload, a
22
+ * fixture, a network response.
23
+ *
24
+ * This is the whole item model, and every adapter renders exactly this. A
25
+ * renderer that can type its components against the registry offers a checked
26
+ * counterpart -- `@evanion/react-widget`'s `WidgetItem<C>` -- and this is what
27
+ * that one widens to.
28
+ */
29
+ export interface AnyWidgetItem<Type extends string = string, Props = object> {
30
+ /** Stable identity for this item, and the key a renderer lists it under. */
31
+ id: string;
32
+ /** Which component to render. Must be a key of the registry. */
33
+ type: Type;
34
+ /**
35
+ * Props for that component.
36
+ *
37
+ * A named field rather than "every key the renderer does not claim for
38
+ * itself". The renderer's own fields would otherwise be reserved words in
39
+ * the CMS's vocabulary, and adding one later would silently take a prop
40
+ * away from every payload already written.
41
+ */
42
+ props: Props;
43
+ /**
44
+ * Placement and presentation data for the item chrome: grid column, span,
45
+ * ordering, CMS edit affordances.
46
+ *
47
+ * Handed to the chrome and never spread into the widget's own props, because
48
+ * where a widget sits is not something the widget should know.
49
+ */
50
+ meta?: WidgetMeta;
51
+ /**
52
+ * Nested items.
53
+ *
54
+ * What a renderer does with them is the runtime's business, and the two
55
+ * runtimes genuinely differ: React renders them as the component's
56
+ * `children`, while an Astro component receives child content through
57
+ * `<slot />` and so is handed them as data to open its own region over.
58
+ */
59
+ children?: AnyWidgetItem[];
60
+ }
61
+ /** A problem found by {@link validateItems}. */
62
+ export interface WidgetProblem {
63
+ /** Index within the item's own sibling list; -1 when the root is not a list. */
64
+ index: number;
65
+ /** The item's `id`, or `-` when it has none usable. */
66
+ id: string;
67
+ /** The item's `type`, or `-` when it has none usable. */
68
+ type: string;
69
+ message: string;
70
+ }
71
+ /**
72
+ * The set of widget types a list may use.
73
+ *
74
+ * A plain list of names is accepted alongside a registry so that a webhook
75
+ * handler or a CI script can validate CMS payloads without importing the
76
+ * components it will never render.
77
+ */
78
+ export type KnownWidgetTypes = WidgetRegistry | readonly string[];
79
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,GAAG,OAAO,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;AAE5D;;;;;GAKG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEjD;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM,EAAE,KAAK,GAAG,MAAM;IACzE,4EAA4E;IAC5E,EAAE,EAAE,MAAM,CAAC;IACX,gEAAgE;IAChE,IAAI,EAAE,IAAI,CAAC;IACX;;;;;;;OAOG;IACH,KAAK,EAAE,KAAK,CAAC;IACb;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,aAAa,EAAE,CAAC;CAC5B;AAED,gDAAgD;AAChD,MAAM,WAAW,aAAa;IAC5B,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;IACd,uDAAuD;IACvD,EAAE,EAAE,MAAM,CAAC;IACX,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,gBAAgB,GAAG,cAAc,GAAG,SAAS,MAAM,EAAE,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import { ReactNode } from 'react';
2
+ import { AnyWidgetComponent, RenderableWidgetItem, WidgetItemComponent, WidgetSuspenseMode } from './types.js';
3
+ /**
4
+ * Renders one item and, recursively, its nested items as that item's children.
5
+ *
6
+ * Internal: not re-exported from the package barrel, so the nesting mechanism
7
+ * stays free to change without a breaking release.
8
+ *
9
+ * The `<Suspense>` boundary lives here rather than in the item chrome, so a
10
+ * custom `chrome.item` cannot silently remove it. `suspense` decides whether
11
+ * there is one to remove; it comes from the chrome, which is the only place
12
+ * that knows whether the region's widgets suspend.
13
+ */
14
+ export declare function renderWidget(item: RenderableWidgetItem, components: Record<string, AnyWidgetComponent>, ItemWrapper: WidgetItemComponent, ctx: Record<string, unknown> | undefined, suspenseFallback: ReactNode, suspense: WidgetSuspenseMode): ReactNode;
15
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,KAAK,EACV,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,oBAAoB,EAC1B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,EAC9C,WAAW,EAAE,mBAAmB,EAChC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACxC,gBAAgB,EAAE,SAAS,EAC3B,QAAQ,EAAE,kBAAkB,GAC3B,SAAS,CAqEX"}
@@ -0,0 +1,29 @@
1
+ import type { KnownWidgetTypes, WidgetProblem } from './types.js';
2
+ /**
3
+ * Checks a widget item list against the set of known types, recursing into
4
+ * `children`.
5
+ *
6
+ * Returns problems rather than throwing, and accumulates rather than
7
+ * short-circuiting, so a caller can print all of them at once. No renderer
8
+ * calls this: an adapter stays defensive -- skip the item and warn -- and
9
+ * validation is the loud, explicit gate run at ingestion or build time.
10
+ *
11
+ * `required` maps a widget type to the prop names that must be present and
12
+ * non-blank on `item.props`, where blank means `undefined`, `null` or
13
+ * whitespace only. It is the only check an Astro widget's props get, because an
14
+ * `.astro` component exposes no prop types to infer from; a React consumer
15
+ * wants it for data that never met `WidgetItem<C>`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * validateItems([{ id: 'a', type: 'nope', props: {} }], ['news']);
20
+ * // [{ index: 0, id: 'a', type: 'nope', message: 'unknown widget type' }]
21
+ *
22
+ * validateItems([{ id: 'a', type: 'hero', props: {} }], ['hero'], {
23
+ * hero: ['heading'],
24
+ * });
25
+ * // [{ index: 0, id: 'a', type: 'hero', message: 'missing field heading' }]
26
+ * ```
27
+ */
28
+ export declare function validateItems(items: unknown, known: KnownWidgetTypes, required?: Record<string, string[]>): WidgetProblem[];
29
+ //# sourceMappingURL=validate-items.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-items.d.ts","sourceRoot":"","sources":["../src/validate-items.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAmClE;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,OAAO,EACd,KAAK,EAAE,gBAAgB,EACvB,QAAQ,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAM,GACtC,aAAa,EAAE,CAiHjB"}
@@ -0,0 +1,165 @@
1
+ import { VALIDATION_MESSAGES } from './constants.js';
2
+ function isPlainObject(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
5
+ /**
6
+ * Own-key lookup against a caller-supplied object.
7
+ *
8
+ * `in` and a bare index both walk the prototype chain, so a type of
9
+ * `constructor`, `toString` or `__proto__` resolves against `Object.prototype`:
10
+ * the type passes as registered, and `required[type]` comes back as a function
11
+ * for the field loop to iterate. Items are CMS data, so any string is
12
+ * reachable.
13
+ */
14
+ function hasOwn(target, key) {
15
+ return Object.prototype.hasOwnProperty.call(target, key);
16
+ }
17
+ function knows(known, type) {
18
+ if (Array.isArray(known))
19
+ return known.includes(type);
20
+ return hasOwn(known, type);
21
+ }
22
+ /**
23
+ * A value a CMS text field that was opened and left empty arrives as.
24
+ */
25
+ function isBlank(value) {
26
+ return (value === undefined ||
27
+ value === null ||
28
+ (typeof value === 'string' && value.trim() === ''));
29
+ }
30
+ /**
31
+ * Checks a widget item list against the set of known types, recursing into
32
+ * `children`.
33
+ *
34
+ * Returns problems rather than throwing, and accumulates rather than
35
+ * short-circuiting, so a caller can print all of them at once. No renderer
36
+ * calls this: an adapter stays defensive -- skip the item and warn -- and
37
+ * validation is the loud, explicit gate run at ingestion or build time.
38
+ *
39
+ * `required` maps a widget type to the prop names that must be present and
40
+ * non-blank on `item.props`, where blank means `undefined`, `null` or
41
+ * whitespace only. It is the only check an Astro widget's props get, because an
42
+ * `.astro` component exposes no prop types to infer from; a React consumer
43
+ * wants it for data that never met `WidgetItem<C>`.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * validateItems([{ id: 'a', type: 'nope', props: {} }], ['news']);
48
+ * // [{ index: 0, id: 'a', type: 'nope', message: 'unknown widget type' }]
49
+ *
50
+ * validateItems([{ id: 'a', type: 'hero', props: {} }], ['hero'], {
51
+ * hero: ['heading'],
52
+ * });
53
+ * // [{ index: 0, id: 'a', type: 'hero', message: 'missing field heading' }]
54
+ * ```
55
+ */
56
+ export function validateItems(items, known, required = {}) {
57
+ if (!Array.isArray(items)) {
58
+ return [
59
+ {
60
+ index: -1,
61
+ id: '-',
62
+ type: '-',
63
+ message: VALIDATION_MESSAGES.NOT_A_LIST,
64
+ },
65
+ ];
66
+ }
67
+ const problems = [];
68
+ const seenIds = new Set();
69
+ items.forEach((item, index) => {
70
+ if (!isPlainObject(item)) {
71
+ problems.push({
72
+ index,
73
+ id: '-',
74
+ type: '-',
75
+ message: VALIDATION_MESSAGES.NOT_AN_OBJECT,
76
+ });
77
+ return;
78
+ }
79
+ const id = typeof item['id'] === 'string' ? item['id'] : '-';
80
+ const type = typeof item['type'] === 'string' ? item['type'] : '-';
81
+ if (id === '-') {
82
+ problems.push({
83
+ index,
84
+ id,
85
+ type,
86
+ message: VALIDATION_MESSAGES.INVALID_ID,
87
+ });
88
+ }
89
+ else if (seenIds.has(id)) {
90
+ // Only within one sibling list: a renderer scopes keys per list, so the
91
+ // same id at different depths is fine.
92
+ problems.push({
93
+ index,
94
+ id,
95
+ type,
96
+ message: VALIDATION_MESSAGES.DUPLICATE_ID,
97
+ });
98
+ }
99
+ else {
100
+ seenIds.add(id);
101
+ }
102
+ if (type === '-') {
103
+ problems.push({
104
+ index,
105
+ id,
106
+ type,
107
+ message: VALIDATION_MESSAGES.INVALID_TYPE,
108
+ });
109
+ }
110
+ else if (!knows(known, type)) {
111
+ problems.push({
112
+ index,
113
+ id,
114
+ type,
115
+ message: VALIDATION_MESSAGES.UNKNOWN_TYPE,
116
+ });
117
+ }
118
+ const props = item['props'];
119
+ // An absent `props` is a problem, not an empty one. A widget's data lives
120
+ // under that key and nowhere else, so an item without it is an item whose
121
+ // props the payload put somewhere the renderer does not read -- which is
122
+ // exactly what a payload written against a flat item shape looks like, and
123
+ // exactly what this check is the migration gate for. A renderer then draws
124
+ // the widget with nothing in it and nothing logged.
125
+ if (!isPlainObject(props)) {
126
+ problems.push({
127
+ index,
128
+ id,
129
+ type,
130
+ message: VALIDATION_MESSAGES.INVALID_PROPS,
131
+ });
132
+ }
133
+ else if (type !== '-' && knows(known, type) && hasOwn(required, type)) {
134
+ // Only for a type the registry declares. An unknown type has already
135
+ // been reported, and listing the fields it did not supply says nothing
136
+ // the first problem did not.
137
+ const fields = required[type] ?? [];
138
+ for (const field of fields) {
139
+ const value = hasOwn(props, field) ? props[field] : undefined;
140
+ if (isBlank(value)) {
141
+ problems.push({
142
+ index,
143
+ id,
144
+ type,
145
+ message: VALIDATION_MESSAGES.MISSING_FIELD(field),
146
+ });
147
+ }
148
+ }
149
+ }
150
+ if (item['children'] !== undefined) {
151
+ if (Array.isArray(item['children'])) {
152
+ problems.push(...validateItems(item['children'], known, required));
153
+ }
154
+ else {
155
+ problems.push({
156
+ index,
157
+ id,
158
+ type,
159
+ message: VALIDATION_MESSAGES.INVALID_CHILDREN,
160
+ });
161
+ }
162
+ }
163
+ });
164
+ return problems;
165
+ }
package/dist/warn.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Dev-only console warning, emitted once per distinct message.
3
+ *
4
+ * A renderer warns from render, so one stale `type` in a CMS payload logs again
5
+ * on every re-render, and twice over for a page that renders on the server and
6
+ * then hydrates. Every message in {@link ERROR_MESSAGES} carries the offending
7
+ * item's `type` and `id`, which is what makes the message text a usable key:
8
+ * each bad item is reported once per process, and a second bad item is still
9
+ * reported separately.
10
+ *
11
+ * Nothing is logged when `NODE_ENV` is `production`. A bundler folds that
12
+ * comparison to `false` and drops the call, so a stale item never reaches an
13
+ * end user's console.
14
+ *
15
+ * Exported for the adapters. A consumer has no reason to call it, and an
16
+ * adapter cannot reach it any other way: the adapters are separate packages,
17
+ * and this one publishes a single entry point.
18
+ */
19
+ export declare function warnOnce(message: string): void;
20
+ /**
21
+ * Clears the set of already-reported messages.
22
+ *
23
+ * A set that lives as long as the process is what a dev server wants and what a
24
+ * test file cannot have: one case's warning would silence the next case that
25
+ * produces the same message. Each adapter's `test-setup` calls this before
26
+ * every test.
27
+ */
28
+ export declare function resetWarnings(): void;
29
+ //# sourceMappingURL=warn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"warn.d.ts","sourceRoot":"","sources":["../src/warn.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAK9C;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,IAAI,IAAI,CAEpC"}
package/dist/warn.js ADDED
@@ -0,0 +1,39 @@
1
+ /** Messages already reported, for the lifetime of the process. */
2
+ const seen = new Set();
3
+ /**
4
+ * Dev-only console warning, emitted once per distinct message.
5
+ *
6
+ * A renderer warns from render, so one stale `type` in a CMS payload logs again
7
+ * on every re-render, and twice over for a page that renders on the server and
8
+ * then hydrates. Every message in {@link ERROR_MESSAGES} carries the offending
9
+ * item's `type` and `id`, which is what makes the message text a usable key:
10
+ * each bad item is reported once per process, and a second bad item is still
11
+ * reported separately.
12
+ *
13
+ * Nothing is logged when `NODE_ENV` is `production`. A bundler folds that
14
+ * comparison to `false` and drops the call, so a stale item never reaches an
15
+ * end user's console.
16
+ *
17
+ * Exported for the adapters. A consumer has no reason to call it, and an
18
+ * adapter cannot reach it any other way: the adapters are separate packages,
19
+ * and this one publishes a single entry point.
20
+ */
21
+ export function warnOnce(message) {
22
+ if (process.env.NODE_ENV === 'production')
23
+ return;
24
+ if (seen.has(message))
25
+ return;
26
+ seen.add(message);
27
+ console.warn(message);
28
+ }
29
+ /**
30
+ * Clears the set of already-reported messages.
31
+ *
32
+ * A set that lives as long as the process is what a dev server wants and what a
33
+ * test file cannot have: one case's warning would silence the next case that
34
+ * produces the same message. Each adapter's `test-setup` calls this before
35
+ * every test.
36
+ */
37
+ export function resetWarnings() {
38
+ seen.clear();
39
+ }
@@ -0,0 +1,36 @@
1
+ import { WidgetComponentMap, WidgetItem, WidgetItemProblem, WidgetMeta, 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
+ * Call it once at module scope. There is no provider and no hook, because
9
+ * React's `react-server` export condition has neither `createContext` nor
10
+ * `useContext` and this package has to be importable from a Server Component.
11
+ *
12
+ * The item `meta` vocabulary is inferred from `chrome.item`. Annotate that
13
+ * component with the shape it reads and every item's `meta` is checked against
14
+ * it; leave it unannotated, or pass no chrome, and `meta` stays any object.
15
+ * There is no type argument to pass by hand: `chrome.item` is the only thing
16
+ * that reads `meta`, so a set with no such chrome has nothing to check against,
17
+ * and naming `M` explicitly would cost the inference of `C`.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * const { Widgets } = createWidgets({
22
+ * components: { news: NewsTeaser, profile: UserSidebar },
23
+ * });
24
+ *
25
+ * <Widgets items={[
26
+ * { id: '1', type: 'news', props: { title: 'Hello' } },
27
+ * { id: '2', type: 'nope', props: {} }, // ← compile error: unknown type
28
+ * ]} />
29
+ * ```
30
+ */
31
+ export declare function createWidgets<const C extends WidgetComponentMap, M = WidgetMeta>(config: WidgetsConfig<C, M>): {
32
+ Widgets: import('react').MemoExoticComponent<({ items, components: instanceComponents, chrome, ctx, }: WidgetsProps<C, M>) => import("react").JSX.Element | null>;
33
+ defineItems: (items: WidgetItem<C, M>[]) => WidgetItem<C, M>[];
34
+ validateItems: (items: unknown) => WidgetItemProblem[];
35
+ };
36
+ //# 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,EAEV,iBAAiB,EACjB,UAAU,EACV,aAAa,EACb,YAAY,EACb,MAAM,YAAY,CAAC;AAOpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,aAAa,CAC3B,KAAK,CAAC,CAAC,SAAS,kBAAkB,EAClC,CAAC,GAAG,UAAU,EACd,MAAM,EAAE,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC;2GAQxB,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;yBAwDO,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,KAAG,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE;2BAQhC,OAAO,KAAG,iBAAiB,EAAE;EAIjE"}
@@ -0,0 +1,25 @@
1
+ import { HTMLProps } from 'react';
2
+ /**
3
+ * Default chrome around the whole set: a `<section>` carrying whatever props it
4
+ * is handed.
5
+ *
6
+ * `<section>` rather than `<div>` because a named section maps to the `region`
7
+ * landmark role (HTML-AAM), so a caller who passes `aria-label` gets a widget
8
+ * region that is reachable by landmark navigation and one who does not is no
9
+ * worse off than with a `<div>`.
10
+ */
11
+ export declare function DefaultWrapper(props: HTMLProps<HTMLDivElement>): import("react").JSX.Element;
12
+ /**
13
+ * Default chrome around one widget: a `<div>` carrying the `data-widget-*`
14
+ * attributes that CMS click-to-edit overlays, analytics and E2E selectors key
15
+ * off.
16
+ *
17
+ * `meta` is dropped rather than forwarded. It is arbitrary consumer data with
18
+ * no meaning to the DOM, and React warns about an unknown attribute on every
19
+ * key of it that reaches an element. A custom `chrome.item` is where meta is
20
+ * read.
21
+ */
22
+ export declare function DefaultItem({ meta: _meta, ...props }: HTMLProps<HTMLDivElement> & {
23
+ meta?: Record<string, unknown>;
24
+ }): import("react").JSX.Element;
25
+ //# 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,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,CAAC,cAAc,CAAC,+BAE9D;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,EAC1B,IAAI,EAAE,KAAK,EACX,GAAG,KAAK,EACT,EAAE,SAAS,CAAC,cAAc,CAAC,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,+BAEhE"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@evanion/widget",
3
+ "version": "0.1.0",
4
+ "description": "The framework-free half of a widget region: the item shape, the registry, and the validator every @evanion widget renderer shares.",
5
+ "keywords": [
6
+ "widget",
7
+ "cms",
8
+ "blocks",
9
+ "registry",
10
+ "validation",
11
+ "typescript"
12
+ ],
13
+ "homepage": "https://github.com/Evanion/libraries/tree/main/libs/widget#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/Evanion/libraries/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/Evanion/libraries.git",
20
+ "directory": "libs/widget"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Mikael Pettersson",
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ "./package.json": "./package.json",
32
+ ".": {
33
+ "@evanion/source": "./src/index.ts",
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js",
36
+ "default": "./dist/index.js"
37
+ }
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "!dist/**/*.tsbuildinfo",
42
+ "src",
43
+ "!src/**/*.test.*",
44
+ "!src/**/*.spec.*",
45
+ "!src/**/*.test-d.*",
46
+ "!src/test-setup.ts",
47
+ "README.md",
48
+ "LICENSE",
49
+ "CHANGELOG.md"
50
+ ],
51
+ "publishConfig": {
52
+ "access": "public",
53
+ "provenance": true
54
+ }
55
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Messages an adapter's renderer passes to {@link warnOnce}.
3
+ *
4
+ * Each one names the offending item's `id` and `type`, which is what lets
5
+ * `warnOnce` key on the message text and still report a second bad item
6
+ * separately.
7
+ *
8
+ * They live here rather than in each adapter so that a region rendered through
9
+ * React and the same region rendered through Astro report a stale CMS type in
10
+ * the same words.
11
+ */
12
+ export const ERROR_MESSAGES = {
13
+ UNKNOWN_WIDGET: (type: string, id: string) =>
14
+ `Unknown widget type "${type}" for widget ID "${id}". Skipping render.`,
15
+ UNKNOWN: 'unknown',
16
+ MALFORMED_ITEMS:
17
+ 'Malformed `items` prop: expected an array of widget items. Skipping render.',
18
+ MALFORMED_ITEM: (id: string | undefined, type: unknown) =>
19
+ `Malformed widget item (id="${id ?? 'unknown'}", type="${typeof type === 'string' ? type : 'unknown'}"). Skipping render.`,
20
+ MALFORMED_CHILDREN: (id: string) =>
21
+ `Malformed \`children\` on widget item (id="${id}"): expected an array. Rendering the widget without them.`,
22
+ } as const;
23
+
24
+ /**
25
+ * Messages reported by {@link validateItems}.
26
+ *
27
+ * Exported so a caller can group or translate problems without matching on
28
+ * prose, and so the tests assert against the same strings the library emits.
29
+ */
30
+ export const VALIDATION_MESSAGES = {
31
+ NOT_A_LIST: 'items is not a list',
32
+ NOT_AN_OBJECT: 'item is not an object',
33
+ INVALID_ID: 'item id is not a string',
34
+ INVALID_TYPE: 'item type is not a string',
35
+ UNKNOWN_TYPE: 'unknown widget type',
36
+ INVALID_PROPS: 'props is not an object',
37
+ INVALID_CHILDREN: 'children is not a list',
38
+ DUPLICATE_ID: 'duplicate sibling id',
39
+ /**
40
+ * A field the caller's `required` map demands is absent or blank.
41
+ *
42
+ * A function rather than a constant because the field name is the whole of
43
+ * the report: a CMS editor reads "missing field heading" and knows which box
44
+ * to fill.
45
+ */
46
+ MISSING_FIELD: (field: string) => `missing field ${field}`,
47
+ } as const;
@@ -0,0 +1,25 @@
1
+ import type { WidgetRegistry } from './types.js';
2
+
3
+ /**
4
+ * Returns the registry unchanged, typed as the literal object that was passed.
5
+ *
6
+ * The generic parameter is the whole point: annotating the same object as
7
+ * `WidgetRegistry` widens its keys to `string`, and the key union is what an
8
+ * editor completes on and what a caller narrows a `required` map against.
9
+ *
10
+ * In the core rather than in an adapter because every adapter needs it, and
11
+ * because a React consumer assembling a registry before handing it to
12
+ * `createWidgets` wants it too.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import Hero from './Hero.astro';
17
+ * import Text from './Text.astro';
18
+ *
19
+ * const registry = defineWidgets({ hero: Hero, text: Text });
20
+ * // ^? { hero: AstroComponentFactory; text: AstroComponentFactory }
21
+ * ```
22
+ */
23
+ export function defineWidgets<R extends WidgetRegistry>(registry: R): R {
24
+ return registry;
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // The framework-free half of a widget region.
2
+ //
3
+ // Nothing here imports a framework, so this package is usable from a webhook
4
+ // handler, a Nest service or a CI script that validates a CMS payload before
5
+ // anything renders it. scripts/verify-packaging.mjs greps the packed `dist/`
6
+ // for a framework import, because that promise breaks silently: the build
7
+ // succeeds and every test passes.
8
+ //
9
+ // The renderers live one package per framework -- `@evanion/react-widget`,
10
+ // `@evanion/astro-widget` -- and each re-exports what its consumers need from
11
+ // here, so a consumer who never names this package never installs it by hand.
12
+
13
+ export * from './types.js';
14
+ export * from './constants.js';
15
+ export * from './define-widgets.js';
16
+ export * from './validate-items.js';
17
+ export * from './warn.js';
package/src/types.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Maps a widget type name to whatever the adapter resolves it to.
3
+ *
4
+ * `T` is the adapter's component type. `@evanion/react-widget` instantiates it
5
+ * as a `ComponentType`, which is what drives its compile-time prop check.
6
+ * `@evanion/astro-widget` leaves it at the default: an `.astro` module's
7
+ * default export is an `AstroComponentFactory` carrying no prop types at all --
8
+ * the props of a `.astro` file live in its frontmatter `Props` interface and
9
+ * are not reachable from the factory's type -- so there is nothing there to
10
+ * infer from and {@link validateItems} covers that ground at build time.
11
+ */
12
+ export type WidgetRegistry<T = unknown> = Record<string, T>;
13
+
14
+ /**
15
+ * The `meta` vocabulary of a widget set whose chrome declares none.
16
+ *
17
+ * Any object, so an item may carry whatever placement data it likes and a
18
+ * chrome that reads `meta` narrows it by hand.
19
+ */
20
+ export type WidgetMeta = Record<string, unknown>;
21
+
22
+ /**
23
+ * Loose item shape, for data built before a registry exists: a CMS payload, a
24
+ * fixture, a network response.
25
+ *
26
+ * This is the whole item model, and every adapter renders exactly this. A
27
+ * renderer that can type its components against the registry offers a checked
28
+ * counterpart -- `@evanion/react-widget`'s `WidgetItem<C>` -- and this is what
29
+ * that one widens to.
30
+ */
31
+ export interface AnyWidgetItem<Type extends string = string, Props = object> {
32
+ /** Stable identity for this item, and the key a renderer lists it under. */
33
+ id: string;
34
+ /** Which component to render. Must be a key of the registry. */
35
+ type: Type;
36
+ /**
37
+ * Props for that component.
38
+ *
39
+ * A named field rather than "every key the renderer does not claim for
40
+ * itself". The renderer's own fields would otherwise be reserved words in
41
+ * the CMS's vocabulary, and adding one later would silently take a prop
42
+ * away from every payload already written.
43
+ */
44
+ props: Props;
45
+ /**
46
+ * Placement and presentation data for the item chrome: grid column, span,
47
+ * ordering, CMS edit affordances.
48
+ *
49
+ * Handed to the chrome and never spread into the widget's own props, because
50
+ * where a widget sits is not something the widget should know.
51
+ */
52
+ meta?: WidgetMeta;
53
+ /**
54
+ * Nested items.
55
+ *
56
+ * What a renderer does with them is the runtime's business, and the two
57
+ * runtimes genuinely differ: React renders them as the component's
58
+ * `children`, while an Astro component receives child content through
59
+ * `<slot />` and so is handed them as data to open its own region over.
60
+ */
61
+ children?: AnyWidgetItem[];
62
+ }
63
+
64
+ /** A problem found by {@link validateItems}. */
65
+ export interface WidgetProblem {
66
+ /** Index within the item's own sibling list; -1 when the root is not a list. */
67
+ index: number;
68
+ /** The item's `id`, or `-` when it has none usable. */
69
+ id: string;
70
+ /** The item's `type`, or `-` when it has none usable. */
71
+ type: string;
72
+ message: string;
73
+ }
74
+
75
+ /**
76
+ * The set of widget types a list may use.
77
+ *
78
+ * A plain list of names is accepted alongside a registry so that a webhook
79
+ * handler or a CI script can validate CMS payloads without importing the
80
+ * components it will never render.
81
+ */
82
+ export type KnownWidgetTypes = WidgetRegistry | readonly string[];
@@ -0,0 +1,180 @@
1
+ import { VALIDATION_MESSAGES } from './constants.js';
2
+ import type { KnownWidgetTypes, WidgetProblem } from './types.js';
3
+
4
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+
8
+ /**
9
+ * Own-key lookup against a caller-supplied object.
10
+ *
11
+ * `in` and a bare index both walk the prototype chain, so a type of
12
+ * `constructor`, `toString` or `__proto__` resolves against `Object.prototype`:
13
+ * the type passes as registered, and `required[type]` comes back as a function
14
+ * for the field loop to iterate. Items are CMS data, so any string is
15
+ * reachable.
16
+ */
17
+ function hasOwn(target: object, key: string): boolean {
18
+ return Object.prototype.hasOwnProperty.call(target, key);
19
+ }
20
+
21
+ function knows(known: KnownWidgetTypes, type: string): boolean {
22
+ if (Array.isArray(known)) return known.includes(type);
23
+ return hasOwn(known, type);
24
+ }
25
+
26
+ /**
27
+ * A value a CMS text field that was opened and left empty arrives as.
28
+ */
29
+ function isBlank(value: unknown): boolean {
30
+ return (
31
+ value === undefined ||
32
+ value === null ||
33
+ (typeof value === 'string' && value.trim() === '')
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Checks a widget item list against the set of known types, recursing into
39
+ * `children`.
40
+ *
41
+ * Returns problems rather than throwing, and accumulates rather than
42
+ * short-circuiting, so a caller can print all of them at once. No renderer
43
+ * calls this: an adapter stays defensive -- skip the item and warn -- and
44
+ * validation is the loud, explicit gate run at ingestion or build time.
45
+ *
46
+ * `required` maps a widget type to the prop names that must be present and
47
+ * non-blank on `item.props`, where blank means `undefined`, `null` or
48
+ * whitespace only. It is the only check an Astro widget's props get, because an
49
+ * `.astro` component exposes no prop types to infer from; a React consumer
50
+ * wants it for data that never met `WidgetItem<C>`.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * validateItems([{ id: 'a', type: 'nope', props: {} }], ['news']);
55
+ * // [{ index: 0, id: 'a', type: 'nope', message: 'unknown widget type' }]
56
+ *
57
+ * validateItems([{ id: 'a', type: 'hero', props: {} }], ['hero'], {
58
+ * hero: ['heading'],
59
+ * });
60
+ * // [{ index: 0, id: 'a', type: 'hero', message: 'missing field heading' }]
61
+ * ```
62
+ */
63
+ export function validateItems(
64
+ items: unknown,
65
+ known: KnownWidgetTypes,
66
+ required: Record<string, string[]> = {},
67
+ ): WidgetProblem[] {
68
+ if (!Array.isArray(items)) {
69
+ return [
70
+ {
71
+ index: -1,
72
+ id: '-',
73
+ type: '-',
74
+ message: VALIDATION_MESSAGES.NOT_A_LIST,
75
+ },
76
+ ];
77
+ }
78
+
79
+ const problems: WidgetProblem[] = [];
80
+ const seenIds = new Set<string>();
81
+
82
+ items.forEach((item: unknown, index) => {
83
+ if (!isPlainObject(item)) {
84
+ problems.push({
85
+ index,
86
+ id: '-',
87
+ type: '-',
88
+ message: VALIDATION_MESSAGES.NOT_AN_OBJECT,
89
+ });
90
+ return;
91
+ }
92
+
93
+ const id = typeof item['id'] === 'string' ? item['id'] : '-';
94
+ const type = typeof item['type'] === 'string' ? item['type'] : '-';
95
+
96
+ if (id === '-') {
97
+ problems.push({
98
+ index,
99
+ id,
100
+ type,
101
+ message: VALIDATION_MESSAGES.INVALID_ID,
102
+ });
103
+ } else if (seenIds.has(id)) {
104
+ // Only within one sibling list: a renderer scopes keys per list, so the
105
+ // same id at different depths is fine.
106
+ problems.push({
107
+ index,
108
+ id,
109
+ type,
110
+ message: VALIDATION_MESSAGES.DUPLICATE_ID,
111
+ });
112
+ } else {
113
+ seenIds.add(id);
114
+ }
115
+
116
+ if (type === '-') {
117
+ problems.push({
118
+ index,
119
+ id,
120
+ type,
121
+ message: VALIDATION_MESSAGES.INVALID_TYPE,
122
+ });
123
+ } else if (!knows(known, type)) {
124
+ problems.push({
125
+ index,
126
+ id,
127
+ type,
128
+ message: VALIDATION_MESSAGES.UNKNOWN_TYPE,
129
+ });
130
+ }
131
+
132
+ const props = item['props'];
133
+
134
+ // An absent `props` is a problem, not an empty one. A widget's data lives
135
+ // under that key and nowhere else, so an item without it is an item whose
136
+ // props the payload put somewhere the renderer does not read -- which is
137
+ // exactly what a payload written against a flat item shape looks like, and
138
+ // exactly what this check is the migration gate for. A renderer then draws
139
+ // the widget with nothing in it and nothing logged.
140
+ if (!isPlainObject(props)) {
141
+ problems.push({
142
+ index,
143
+ id,
144
+ type,
145
+ message: VALIDATION_MESSAGES.INVALID_PROPS,
146
+ });
147
+ } else if (type !== '-' && knows(known, type) && hasOwn(required, type)) {
148
+ // Only for a type the registry declares. An unknown type has already
149
+ // been reported, and listing the fields it did not supply says nothing
150
+ // the first problem did not.
151
+ const fields = required[type] ?? [];
152
+ for (const field of fields) {
153
+ const value = hasOwn(props, field) ? props[field] : undefined;
154
+ if (isBlank(value)) {
155
+ problems.push({
156
+ index,
157
+ id,
158
+ type,
159
+ message: VALIDATION_MESSAGES.MISSING_FIELD(field),
160
+ });
161
+ }
162
+ }
163
+ }
164
+
165
+ if (item['children'] !== undefined) {
166
+ if (Array.isArray(item['children'])) {
167
+ problems.push(...validateItems(item['children'], known, required));
168
+ } else {
169
+ problems.push({
170
+ index,
171
+ id,
172
+ type,
173
+ message: VALIDATION_MESSAGES.INVALID_CHILDREN,
174
+ });
175
+ }
176
+ }
177
+ });
178
+
179
+ return problems;
180
+ }
package/src/warn.ts ADDED
@@ -0,0 +1,39 @@
1
+ /** Messages already reported, for the lifetime of the process. */
2
+ const seen = new Set<string>();
3
+
4
+ /**
5
+ * Dev-only console warning, emitted once per distinct message.
6
+ *
7
+ * A renderer warns from render, so one stale `type` in a CMS payload logs again
8
+ * on every re-render, and twice over for a page that renders on the server and
9
+ * then hydrates. Every message in {@link ERROR_MESSAGES} carries the offending
10
+ * item's `type` and `id`, which is what makes the message text a usable key:
11
+ * each bad item is reported once per process, and a second bad item is still
12
+ * reported separately.
13
+ *
14
+ * Nothing is logged when `NODE_ENV` is `production`. A bundler folds that
15
+ * comparison to `false` and drops the call, so a stale item never reaches an
16
+ * end user's console.
17
+ *
18
+ * Exported for the adapters. A consumer has no reason to call it, and an
19
+ * adapter cannot reach it any other way: the adapters are separate packages,
20
+ * and this one publishes a single entry point.
21
+ */
22
+ export function warnOnce(message: string): void {
23
+ if (process.env.NODE_ENV === 'production') return;
24
+ if (seen.has(message)) return;
25
+ seen.add(message);
26
+ console.warn(message);
27
+ }
28
+
29
+ /**
30
+ * Clears the set of already-reported messages.
31
+ *
32
+ * A set that lives as long as the process is what a dev server wants and what a
33
+ * test file cannot have: one case's warning would silence the next case that
34
+ * produces the same message. Each adapter's `test-setup` calls this before
35
+ * every test.
36
+ */
37
+ export function resetWarnings(): void {
38
+ seen.clear();
39
+ }