@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 +21 -0
- package/README.md +171 -0
- package/dist/constants.d.ts +43 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +42 -0
- package/dist/define-widgets.d.ts +23 -0
- package/dist/define-widgets.d.ts.map +1 -0
- package/dist/define-widgets.js +23 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/validate-items.d.ts +29 -0
- package/dist/validate-items.d.ts.map +1 -0
- package/dist/validate-items.js +165 -0
- package/dist/warn.d.ts +29 -0
- package/dist/warn.d.ts.map +1 -0
- package/dist/warn.js +39 -0
- package/dist/widget.d.ts +36 -0
- package/dist/widget.d.ts.map +1 -0
- package/dist/widgets.d.ts +25 -0
- package/dist/widgets.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/constants.ts +47 -0
- package/src/define-widgets.ts +25 -0
- package/src/index.ts +17 -0
- package/src/types.ts +82 -0
- package/src/validate-items.ts +180 -0
- package/src/warn.ts +39 -0
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
|
+
[](https://www.npmjs.com/package/@evanion/widget)
|
|
2
|
+
[](https://www.npmjs.com/package/@evanion/widget)
|
|
3
|
+
[](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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|
package/dist/types.d.ts
ADDED
|
@@ -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 {};
|
package/dist/utils.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/widget.d.ts
ADDED
|
@@ -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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|