@colixsystems/widget-sdk 0.4.1 → 0.6.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/README.md +17 -4
- package/dist/contract.cjs +64 -35
- package/dist/contract.js +421 -13
- package/dist/hooks.js +21 -7
- package/dist/index.d.ts +41 -16
- package/dist/index.js +13 -1
- package/dist/index.native.js +13 -1
- package/dist/linter.cjs +104 -0
- package/dist/manifest.js +141 -12
- package/dist/primitives.js +35 -51
- package/dist/primitives.native.js +20 -10
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -6,9 +6,22 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
9
|
-
`v0.
|
|
9
|
+
`v0.6.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
10
10
|
|
|
11
|
-
### What's new in 0.
|
|
11
|
+
### What's new in 0.6.0
|
|
12
|
+
|
|
13
|
+
- **Primitives are now React Native through-and-through.** Web (Studio + Player) bundles with `react-native-web`; native (exported Expo app) uses the real `react-native`. The hand-written DOM wrappers in `primitives.js` are gone — both web and native files are now one-line re-exports from `react-native`. The widget API surface is unchanged for the components that already existed (`Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`), so existing 0.5.0 widgets keep running without source edits.
|
|
14
|
+
- **More primitives available for free.** `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, and `StyleSheet` are now exported alongside the original five. Authors who want them just import from `@colixsystems/widget-sdk` like any other primitive.
|
|
15
|
+
- **Per-component API docs link out to React Native.** The Developer Widgets page and the AI agent's system prompt now point at https://reactnative.dev/docs/<component> for prop details instead of duplicating them in the SDK contract. Less drift, more breadth (every RN prop — `accessibilityLabel`, `testID`, gesture handlers, …, works without explicit allowlisting).
|
|
16
|
+
- **Adding a new primitive is now a single edit.** Append the export name to `primitives.js` + `primitives.native.js` + the `CONTRACT.primitives` array. No web/native implementation split.
|
|
17
|
+
- **New peer dep.** `react-native-web >=0.19.0` (optional, marked under `peerDependenciesMeta`). The web bundler picks it up via an alias (`react-native` → `react-native-web` in the host's Vite / webpack config); the native bundler ignores it.
|
|
18
|
+
|
|
19
|
+
### What was in 0.5.0
|
|
20
|
+
|
|
21
|
+
- **`TextInput` primitive.** Cross-platform controlled text field — web maps to `<input type="text">` (or `<textarea>` when `multiline` is true), native maps to `react-native` `TextInput`. Props: `value`, `onChangeText`, `placeholder`, `multiline`, `rows`, `disabled`, `style`. Fixes the gap that forced widgets to fall back to raw `<textarea>` (web-only). The AI Widget Agent's system prompt now documents this primitive.
|
|
22
|
+
- **`useDatastoreMutation(...).update(id, partial)` is wired.** Hits `PATCH /api/v1/tables/:tableId/records/:id`, which now exists on the backend (REQ-DML-PATCH). Partial-update semantics — only the supplied columns are mutated, the rest of the row is left intact. MANY_TO_MANY relations follow replace-set semantics when supplied; omitting the column leaves the existing links alone. Constraint enforcement (REQ-CONS-04) runs against the merged row, excluding self so a re-affirm doesn't trip its own UNIQUE.
|
|
23
|
+
|
|
24
|
+
### What was in 0.4.1
|
|
12
25
|
|
|
13
26
|
- **`useDatastoreQuery` returns a stable `refetch` identity.** The hook no longer rebinds the underlying callback when the host's `WidgetContext` value (a fresh object identity on every render in Studio + PageRenderer) changes. Widgets that put `refetch` in a `useEffect` dep array no longer loop.
|
|
14
27
|
|
|
@@ -17,7 +30,7 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
17
30
|
- **`CONTRACT` is now a named export.** A frozen object literal describing the SDK surface every consumer (LLM system prompt, static analyzer, host builder, contract tests) derives from instead of declaring its own copy. See `docs/design/ai-widget-contract.md` for the full design and `src/contract.js` for the source. The contract carries:
|
|
18
31
|
|
|
19
32
|
- `hooks` — name, signature, return shape, required `WidgetContext` slices, manifest scopes.
|
|
20
|
-
- `primitives` —
|
|
33
|
+
- `primitives` — the cross-platform primitive list (names + the React Native component each one backs).
|
|
21
34
|
- `manifestSchema` — the authoritative `WidgetManifest` field list (with `id` and `minAppStudioVersion`, not the legacy `manifestId` / `minAppStudioVer`).
|
|
22
35
|
- `themeTokens` — the default `useTheme()` payload.
|
|
23
36
|
- `widgetContextShape` — what the host must populate.
|
|
@@ -44,7 +57,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
44
57
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
45
58
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
46
59
|
- `useDatastoreQuery`, `useDatastoreMutation`, `useWidgetEvent`, `useTheme`, `useI18n` — hooks that read from the host-provided `WidgetContext`.
|
|
47
|
-
- `Text`, `View`, `Pressable`, `Image`, `ScrollView` —
|
|
60
|
+
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet` — re-exported from `react-native`. The web build aliases `react-native` to `react-native-web` so widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. See https://reactnative.dev/docs/ for per-component props.
|
|
48
61
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
49
62
|
|
|
50
63
|
## Linter
|
package/dist/contract.cjs
CHANGED
|
@@ -87,61 +87,90 @@ const HOOKS = [
|
|
|
87
87
|
},
|
|
88
88
|
];
|
|
89
89
|
|
|
90
|
+
// REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
|
|
91
|
+
// (backed by react-native-web on the browser, by react-native on the
|
|
92
|
+
// exported Expo app). The contract describes the *cross-platform
|
|
93
|
+
// subset* the SDK re-exports; the per-prop reference for each
|
|
94
|
+
// component is whatever React Native documents. Adding a new primitive
|
|
95
|
+
// = add the name to `./primitives.js` + `./primitives.native.js` (both
|
|
96
|
+
// one-line re-exports) and append it to this list.
|
|
90
97
|
const PRIMITIVES = [
|
|
91
98
|
{
|
|
92
99
|
name: "Text",
|
|
93
100
|
description:
|
|
94
|
-
"Text node.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
style: { type: "CSSObject | StyleObject", description: "Inline style." },
|
|
98
|
-
},
|
|
101
|
+
"Text node. Backed by react-native Text (web: react-native-web maps to a styled <div>). See https://reactnative.dev/docs/text.",
|
|
102
|
+
rnComponent: "Text",
|
|
103
|
+
docsUrl: "https://reactnative.dev/docs/text",
|
|
99
104
|
},
|
|
100
105
|
{
|
|
101
106
|
name: "View",
|
|
102
107
|
description:
|
|
103
|
-
"Block container.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
style: { type: "CSSObject | StyleObject" },
|
|
107
|
-
},
|
|
108
|
+
"Block container. Backed by react-native View. The cross-platform equivalent of a <div>. See https://reactnative.dev/docs/view.",
|
|
109
|
+
rnComponent: "View",
|
|
110
|
+
docsUrl: "https://reactnative.dev/docs/view",
|
|
108
111
|
},
|
|
109
112
|
{
|
|
110
113
|
name: "Pressable",
|
|
111
114
|
description:
|
|
112
|
-
"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
style: { type: "CSSObject | StyleObject" },
|
|
116
|
-
onPress: {
|
|
117
|
-
type: "(event) => void",
|
|
118
|
-
description: "Fires on click/tap. Web preventDefault is auto-applied.",
|
|
119
|
-
},
|
|
120
|
-
},
|
|
115
|
+
"Tappable / clickable wrapper. Use `onPress` (NOT `onClick`). See https://reactnative.dev/docs/pressable.",
|
|
116
|
+
rnComponent: "Pressable",
|
|
117
|
+
docsUrl: "https://reactnative.dev/docs/pressable",
|
|
121
118
|
},
|
|
122
119
|
{
|
|
123
120
|
name: "Image",
|
|
124
121
|
description:
|
|
125
|
-
"Image.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
type: "{ uri: string } | string",
|
|
129
|
-
required: true,
|
|
130
|
-
description: "Source object or bare URL string.",
|
|
131
|
-
},
|
|
132
|
-
alt: { type: "string", description: "Alt text for accessibility." },
|
|
133
|
-
style: { type: "CSSObject | StyleObject" },
|
|
134
|
-
},
|
|
122
|
+
"Image. `source` accepts `{ uri }` or a string URL. See https://reactnative.dev/docs/image.",
|
|
123
|
+
rnComponent: "Image",
|
|
124
|
+
docsUrl: "https://reactnative.dev/docs/image",
|
|
135
125
|
},
|
|
136
126
|
{
|
|
137
127
|
name: "ScrollView",
|
|
138
128
|
description:
|
|
139
|
-
"Scrollable container. Pass `horizontal` to scroll on the x-axis.",
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
129
|
+
"Scrollable container. Pass `horizontal` to scroll on the x-axis. See https://reactnative.dev/docs/scrollview.",
|
|
130
|
+
rnComponent: "ScrollView",
|
|
131
|
+
docsUrl: "https://reactnative.dev/docs/scrollview",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "TextInput",
|
|
135
|
+
description:
|
|
136
|
+
"Single-line or multi-line text field. Controlled — pass `value` + `onChangeText`. Set `multiline` for a textarea-style field (`numberOfLines` controls visible rows). See https://reactnative.dev/docs/textinput.",
|
|
137
|
+
rnComponent: "TextInput",
|
|
138
|
+
docsUrl: "https://reactnative.dev/docs/textinput",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "FlatList",
|
|
142
|
+
description:
|
|
143
|
+
"Virtualised list. Render large arrays without paying for every row up front. Required props: `data`, `renderItem`, `keyExtractor`. See https://reactnative.dev/docs/flatlist.",
|
|
144
|
+
rnComponent: "FlatList",
|
|
145
|
+
docsUrl: "https://reactnative.dev/docs/flatlist",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "SectionList",
|
|
149
|
+
description:
|
|
150
|
+
"Like FlatList but grouped by section header. See https://reactnative.dev/docs/sectionlist.",
|
|
151
|
+
rnComponent: "SectionList",
|
|
152
|
+
docsUrl: "https://reactnative.dev/docs/sectionlist",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "ActivityIndicator",
|
|
156
|
+
description:
|
|
157
|
+
"Loading spinner. See https://reactnative.dev/docs/activityindicator.",
|
|
158
|
+
rnComponent: "ActivityIndicator",
|
|
159
|
+
docsUrl: "https://reactnative.dev/docs/activityindicator",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "Switch",
|
|
163
|
+
description:
|
|
164
|
+
"Toggle. Controlled — `value` + `onValueChange`. See https://reactnative.dev/docs/switch.",
|
|
165
|
+
rnComponent: "Switch",
|
|
166
|
+
docsUrl: "https://reactnative.dev/docs/switch",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "StyleSheet",
|
|
170
|
+
description:
|
|
171
|
+
"Helper for building style objects. `StyleSheet.create({ ... })` returns the same shape you can pass to `style={...}`. See https://reactnative.dev/docs/stylesheet.",
|
|
172
|
+
rnComponent: "StyleSheet",
|
|
173
|
+
docsUrl: "https://reactnative.dev/docs/stylesheet",
|
|
145
174
|
},
|
|
146
175
|
];
|
|
147
176
|
|
package/dist/contract.js
CHANGED
|
@@ -1,17 +1,425 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// rationale.
|
|
1
|
+
// ESM mirror of contract.cjs. Generated from contract.cjs — keep both in lockstep.
|
|
2
|
+
// runtime) can `require()` it directly without dynamic import gymnastics.
|
|
4
3
|
//
|
|
5
|
-
// The
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// The ESM `contract.js` re-exports the SAME object as the `CONTRACT` named
|
|
5
|
+
// export. The contract test asserts the two are identical (deep-equal) so
|
|
6
|
+
// drift between this file and the ESM source-of-truth is caught in CI.
|
|
7
|
+
//
|
|
8
|
+
// Keep edits in lockstep between `contract.cjs` and `contract.js`. The
|
|
9
|
+
// SDK build script copies both into `dist/`.
|
|
10
|
+
|
|
11
|
+
const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
12
|
+
colors: Object.freeze({
|
|
13
|
+
primary: "#ff6b5b",
|
|
14
|
+
onPrimary: "#ffffff",
|
|
15
|
+
surface: "#ffffff",
|
|
16
|
+
onSurface: "#111827",
|
|
17
|
+
surfaceMuted: "#f8fafc",
|
|
18
|
+
onSurfaceMuted: "#475569",
|
|
19
|
+
border: "#e2e8f0",
|
|
20
|
+
danger: "#dc2626",
|
|
21
|
+
success: "#059669",
|
|
22
|
+
warning: "#d97706",
|
|
23
|
+
info: "#0284c7",
|
|
24
|
+
}),
|
|
25
|
+
spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
|
|
26
|
+
radii: Object.freeze({ sm: 4, md: 8, lg: 16, pill: 9999 }),
|
|
27
|
+
typography: Object.freeze({
|
|
28
|
+
fontFamily:
|
|
29
|
+
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
30
|
+
sizes: Object.freeze({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }),
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const HOOKS = [
|
|
35
|
+
{
|
|
36
|
+
name: "useTheme",
|
|
37
|
+
signature: "useTheme()",
|
|
38
|
+
returnShape: {
|
|
39
|
+
colors:
|
|
40
|
+
"{ primary, onPrimary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }",
|
|
41
|
+
spacing: "{ xs, sm, md, lg, xl }",
|
|
42
|
+
radii: "{ sm, md, lg, pill }",
|
|
43
|
+
typography: "{ fontFamily, sizes: { xs, sm, md, lg, xl, xxl } }",
|
|
44
|
+
},
|
|
45
|
+
requiredContextSlice: ["workspace.theme"],
|
|
46
|
+
scopes: null,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "useI18n",
|
|
50
|
+
signature: "useI18n()",
|
|
51
|
+
returnShape: {
|
|
52
|
+
t: "(key: string, fallback?: string) => string",
|
|
53
|
+
locale: "string",
|
|
54
|
+
},
|
|
55
|
+
requiredContextSlice: ["i18n.t", "i18n.locale"],
|
|
56
|
+
scopes: null,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "useDatastoreQuery",
|
|
60
|
+
signature: "useDatastoreQuery(tableId, options?)",
|
|
61
|
+
returnShape: {
|
|
62
|
+
data: "Record[]",
|
|
63
|
+
loading: "boolean",
|
|
64
|
+
error: "DatastoreError | null",
|
|
65
|
+
refetch: "() => Promise<void>",
|
|
66
|
+
},
|
|
67
|
+
requiredContextSlice: ["datastore.records"],
|
|
68
|
+
scopes: ["datastore.read:*"],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "useDatastoreMutation",
|
|
72
|
+
signature: "useDatastoreMutation(tableId)",
|
|
73
|
+
returnShape: {
|
|
74
|
+
create: "(record) => Promise<Record> // rejects with DatastoreError",
|
|
75
|
+
update:
|
|
76
|
+
"(id, partial) => Promise<Record> // rejects with DatastoreError",
|
|
77
|
+
delete: "(id) => Promise<void> // rejects with DatastoreError",
|
|
78
|
+
},
|
|
79
|
+
requiredContextSlice: ["datastore.records"],
|
|
80
|
+
scopes: ["datastore.write:*"],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "useWidgetEvent",
|
|
84
|
+
signature: "useWidgetEvent(eventName)",
|
|
85
|
+
returnShape: { emit: "(payload?) => void" },
|
|
86
|
+
requiredContextSlice: ["events.emit"],
|
|
87
|
+
scopes: null,
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// REQ-WSDK-RN-WEB: see contract.cjs for the source-of-truth comment.
|
|
92
|
+
const PRIMITIVES = [
|
|
93
|
+
{
|
|
94
|
+
name: "Text",
|
|
95
|
+
description:
|
|
96
|
+
"Text node. Backed by react-native Text (web: react-native-web maps to a styled <div>). See https://reactnative.dev/docs/text.",
|
|
97
|
+
rnComponent: "Text",
|
|
98
|
+
docsUrl: "https://reactnative.dev/docs/text",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "View",
|
|
102
|
+
description:
|
|
103
|
+
"Block container. Backed by react-native View. The cross-platform equivalent of a <div>. See https://reactnative.dev/docs/view.",
|
|
104
|
+
rnComponent: "View",
|
|
105
|
+
docsUrl: "https://reactnative.dev/docs/view",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "Pressable",
|
|
109
|
+
description:
|
|
110
|
+
"Tappable / clickable wrapper. Use `onPress` (NOT `onClick`). See https://reactnative.dev/docs/pressable.",
|
|
111
|
+
rnComponent: "Pressable",
|
|
112
|
+
docsUrl: "https://reactnative.dev/docs/pressable",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "Image",
|
|
116
|
+
description:
|
|
117
|
+
"Image. `source` accepts `{ uri }` or a string URL. See https://reactnative.dev/docs/image.",
|
|
118
|
+
rnComponent: "Image",
|
|
119
|
+
docsUrl: "https://reactnative.dev/docs/image",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "ScrollView",
|
|
123
|
+
description:
|
|
124
|
+
"Scrollable container. Pass `horizontal` to scroll on the x-axis. See https://reactnative.dev/docs/scrollview.",
|
|
125
|
+
rnComponent: "ScrollView",
|
|
126
|
+
docsUrl: "https://reactnative.dev/docs/scrollview",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "TextInput",
|
|
130
|
+
description:
|
|
131
|
+
"Single-line or multi-line text field. Controlled — pass `value` + `onChangeText`. Set `multiline` for a textarea-style field (`numberOfLines` controls visible rows). See https://reactnative.dev/docs/textinput.",
|
|
132
|
+
rnComponent: "TextInput",
|
|
133
|
+
docsUrl: "https://reactnative.dev/docs/textinput",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "FlatList",
|
|
137
|
+
description:
|
|
138
|
+
"Virtualised list. Render large arrays without paying for every row up front. Required props: `data`, `renderItem`, `keyExtractor`. See https://reactnative.dev/docs/flatlist.",
|
|
139
|
+
rnComponent: "FlatList",
|
|
140
|
+
docsUrl: "https://reactnative.dev/docs/flatlist",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "SectionList",
|
|
144
|
+
description:
|
|
145
|
+
"Like FlatList but grouped by section header. See https://reactnative.dev/docs/sectionlist.",
|
|
146
|
+
rnComponent: "SectionList",
|
|
147
|
+
docsUrl: "https://reactnative.dev/docs/sectionlist",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: "ActivityIndicator",
|
|
151
|
+
description:
|
|
152
|
+
"Loading spinner. See https://reactnative.dev/docs/activityindicator.",
|
|
153
|
+
rnComponent: "ActivityIndicator",
|
|
154
|
+
docsUrl: "https://reactnative.dev/docs/activityindicator",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "Switch",
|
|
158
|
+
description:
|
|
159
|
+
"Toggle. Controlled — `value` + `onValueChange`. See https://reactnative.dev/docs/switch.",
|
|
160
|
+
rnComponent: "Switch",
|
|
161
|
+
docsUrl: "https://reactnative.dev/docs/switch",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "StyleSheet",
|
|
165
|
+
description:
|
|
166
|
+
"Helper for building style objects. `StyleSheet.create({ ... })` returns the same shape you can pass to `style={...}`. See https://reactnative.dev/docs/stylesheet.",
|
|
167
|
+
rnComponent: "StyleSheet",
|
|
168
|
+
docsUrl: "https://reactnative.dev/docs/stylesheet",
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const CATEGORIES = [
|
|
173
|
+
"INPUT",
|
|
174
|
+
"DISPLAY",
|
|
175
|
+
"LAYOUT",
|
|
176
|
+
"DATA",
|
|
177
|
+
"MEDIA",
|
|
178
|
+
"COMMUNICATION",
|
|
179
|
+
"CUSTOM",
|
|
180
|
+
];
|
|
181
|
+
const PLATFORMS = ["web", "native"];
|
|
182
|
+
|
|
183
|
+
// Reverse-DNS-ish manifest id, e.g. "com.acme.charts.barchart". Two or
|
|
184
|
+
// more labels, lowercase alnum + hyphen, label starts with a letter. The
|
|
185
|
+
// analyzer + the SDK validator both read this from the contract so a
|
|
186
|
+
// rename / relaxation can only happen in one place.
|
|
187
|
+
const MANIFEST_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
|
188
|
+
|
|
189
|
+
const MANIFEST_SCHEMA = {
|
|
190
|
+
id: {
|
|
191
|
+
type: "string",
|
|
192
|
+
required: true,
|
|
193
|
+
description: "Reverse-DNS identifier, e.g. com.acme.hello.",
|
|
194
|
+
example: "com.acme.hello",
|
|
195
|
+
pattern: MANIFEST_ID_PATTERN,
|
|
196
|
+
},
|
|
197
|
+
name: {
|
|
198
|
+
type: "string",
|
|
199
|
+
required: true,
|
|
200
|
+
description: "Human-readable widget name.",
|
|
201
|
+
},
|
|
202
|
+
version: {
|
|
203
|
+
type: "string",
|
|
204
|
+
required: true,
|
|
205
|
+
description: "Semver. Patch bump per publish unless author overrides.",
|
|
206
|
+
example: "1.0.0",
|
|
207
|
+
},
|
|
208
|
+
category: {
|
|
209
|
+
type: "enum",
|
|
210
|
+
required: true,
|
|
211
|
+
description:
|
|
212
|
+
"One of " + CATEGORIES.join(", ") + ". Drives the palette section.",
|
|
213
|
+
values: CATEGORIES,
|
|
214
|
+
},
|
|
215
|
+
icon: {
|
|
216
|
+
type: "string",
|
|
217
|
+
required: true,
|
|
218
|
+
description: "Icon identifier, e.g. lucide:sparkles.",
|
|
219
|
+
default: "lucide:sparkles",
|
|
220
|
+
},
|
|
221
|
+
description: {
|
|
222
|
+
type: "string",
|
|
223
|
+
required: true,
|
|
224
|
+
description: "1-2 sentence storefront description.",
|
|
225
|
+
},
|
|
226
|
+
author: {
|
|
227
|
+
type: "object",
|
|
228
|
+
required: true,
|
|
229
|
+
description: "{ name: string, url?: string, email?: string }",
|
|
230
|
+
},
|
|
231
|
+
supportedPlatforms: {
|
|
232
|
+
type: "string[]",
|
|
233
|
+
required: true,
|
|
234
|
+
description: "Subset of " + PLATFORMS.join(", ") + ".",
|
|
235
|
+
default: ["web"],
|
|
236
|
+
},
|
|
237
|
+
minAppStudioVersion: {
|
|
238
|
+
type: "string",
|
|
239
|
+
required: true,
|
|
240
|
+
description: "Semver range, e.g. >=2.4.0.",
|
|
241
|
+
default: ">=0.1.0",
|
|
242
|
+
},
|
|
243
|
+
requestedScopes: {
|
|
244
|
+
type: "string[]",
|
|
245
|
+
required: true,
|
|
246
|
+
description:
|
|
247
|
+
"Permission scopes; use [] unless the widget reads/writes data.",
|
|
248
|
+
default: [],
|
|
249
|
+
},
|
|
250
|
+
propertySchema: {
|
|
251
|
+
type: "object",
|
|
252
|
+
required: true,
|
|
253
|
+
description: "Map of prop name to property definition.",
|
|
254
|
+
default: {},
|
|
255
|
+
},
|
|
256
|
+
events: {
|
|
257
|
+
type: "object[]",
|
|
258
|
+
required: true,
|
|
259
|
+
description: "Declared events. Each entry { name, payloadSchema? }.",
|
|
260
|
+
default: [],
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const WIDGET_CONTEXT_SHAPE = {
|
|
265
|
+
props: {
|
|
266
|
+
description: "Resolved property values, shape per manifest.propertySchema.",
|
|
267
|
+
required: true,
|
|
268
|
+
fields: {},
|
|
269
|
+
},
|
|
270
|
+
widget: {
|
|
271
|
+
description:
|
|
272
|
+
"Identity of the currently rendered widget instance ({ id, version }).",
|
|
273
|
+
required: true,
|
|
274
|
+
fields: { id: "manifest.id", version: "manifest.version" },
|
|
275
|
+
},
|
|
276
|
+
user: {
|
|
277
|
+
description: "Signed-in user ({ id, email, displayName }).",
|
|
278
|
+
required: true,
|
|
279
|
+
fields: { id: "string", email: "string", displayName: "string" },
|
|
280
|
+
},
|
|
281
|
+
workspace: {
|
|
282
|
+
description:
|
|
283
|
+
"Workspace identity + resolved theme ({ id, slug, theme, locale }).",
|
|
284
|
+
required: true,
|
|
285
|
+
fields: {
|
|
286
|
+
id: "string",
|
|
287
|
+
slug: "string",
|
|
288
|
+
theme: "ThemeTokens",
|
|
289
|
+
locale: "string",
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
navigation: {
|
|
293
|
+
description: "{ push(path), replace(path), back() }.",
|
|
294
|
+
required: true,
|
|
295
|
+
fields: { push: "function", replace: "function", back: "function" },
|
|
296
|
+
},
|
|
297
|
+
datastore: {
|
|
298
|
+
description:
|
|
299
|
+
"{ records(table) -> { list(query?), get(id), create(values), update(id, values), delete(id) } }.",
|
|
300
|
+
required: true,
|
|
301
|
+
fields: { records: "function" },
|
|
302
|
+
},
|
|
303
|
+
events: {
|
|
304
|
+
description: "{ emit(name, payload) }.",
|
|
305
|
+
required: true,
|
|
306
|
+
fields: { emit: "function" },
|
|
307
|
+
},
|
|
308
|
+
i18n: {
|
|
309
|
+
description: "{ t(key, fallback?), locale }.",
|
|
310
|
+
required: true,
|
|
311
|
+
fields: { t: "function", locale: "string" },
|
|
312
|
+
},
|
|
313
|
+
logger: {
|
|
314
|
+
description:
|
|
315
|
+
"{ debug, info, warn, error } — host-routed; never use console.*.",
|
|
316
|
+
required: true,
|
|
317
|
+
fields: {
|
|
318
|
+
debug: "function",
|
|
319
|
+
info: "function",
|
|
320
|
+
warn: "function",
|
|
321
|
+
error: "function",
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const BUNDLE_EXPORT_CONTRACT = [
|
|
327
|
+
{
|
|
328
|
+
name: "wrapped",
|
|
329
|
+
description:
|
|
330
|
+
"default export = defineWidget({ manifest, component }) → resolves to { manifest, component, _kind: 'widget' }. Public marketplace widgets.",
|
|
331
|
+
predicate:
|
|
332
|
+
"typeof mod.default === 'object' && typeof mod.default.component === 'function' && typeof mod.default.manifest === 'object'",
|
|
333
|
+
manifestSource: "mod.default.manifest",
|
|
334
|
+
audience: "public-marketplace",
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: "bare-component",
|
|
338
|
+
description:
|
|
339
|
+
"default export = function MyWidget(props) {...} → host pairs it with the installation's pinnedVersion.manifestJson. AI-agent widgets.",
|
|
340
|
+
predicate: "typeof mod.default === 'function'",
|
|
341
|
+
manifestSource: "installation.pinnedVersion.manifestJson",
|
|
342
|
+
audience: "ai-agent",
|
|
343
|
+
},
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
const BANNED_APIS = [
|
|
347
|
+
{ identifier: "eval", reason: "Arbitrary code evaluation." },
|
|
348
|
+
{
|
|
349
|
+
identifier: "Function",
|
|
350
|
+
reason: "Function() constructor evaluates strings.",
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
identifier: "new Function",
|
|
354
|
+
reason: "Function() constructor evaluates strings.",
|
|
355
|
+
},
|
|
356
|
+
{ identifier: "window", reason: "Host environment escape." },
|
|
357
|
+
{
|
|
358
|
+
identifier: "document",
|
|
359
|
+
reason: "Host environment escape; use SDK primitives.",
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
identifier: "process",
|
|
363
|
+
reason: "Node global; unavailable in the browser, banned for parity.",
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
identifier: "localStorage",
|
|
367
|
+
reason: "Bypasses host data model; not portable to native.",
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
identifier: "sessionStorage",
|
|
371
|
+
reason: "Same reason as localStorage.",
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
identifier: "fetch",
|
|
375
|
+
reason: "Direct network calls bypass the datastore client + tenant auth.",
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
identifier: "XMLHttpRequest",
|
|
379
|
+
reason: "Direct network calls bypass the datastore client + tenant auth.",
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
identifier: "import(",
|
|
383
|
+
reason: "Dynamic import bypasses the loader's allowlist.",
|
|
384
|
+
},
|
|
385
|
+
{ identifier: "globalThis", reason: "Host environment escape." },
|
|
386
|
+
];
|
|
387
|
+
|
|
388
|
+
const ALLOWED_BARE_IMPORTS = ["react", "@colixsystems/widget-sdk"];
|
|
389
|
+
|
|
390
|
+
function deepFreeze(value) {
|
|
391
|
+
if (value === null || typeof value !== "object") return value;
|
|
392
|
+
if (Object.isFrozen(value)) return value;
|
|
393
|
+
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
394
|
+
return Object.freeze(value);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const CONTRACT = deepFreeze({
|
|
398
|
+
version: "1.0.0",
|
|
399
|
+
hooks: HOOKS,
|
|
400
|
+
primitives: PRIMITIVES,
|
|
401
|
+
manifestSchema: MANIFEST_SCHEMA,
|
|
402
|
+
manifestCategories: CATEGORIES,
|
|
403
|
+
manifestPlatforms: PLATFORMS,
|
|
404
|
+
themeTokens: DEFAULT_THEME_TOKENS,
|
|
405
|
+
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
406
|
+
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
407
|
+
bannedApis: BANNED_APIS,
|
|
408
|
+
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
|
409
|
+
});
|
|
9
410
|
|
|
10
|
-
|
|
411
|
+
function isHookAllowed(name) {
|
|
412
|
+
return CONTRACT.hooks.some((h) => h.name === name);
|
|
413
|
+
}
|
|
11
414
|
|
|
12
|
-
|
|
13
|
-
const
|
|
415
|
+
function requiredContextKeys() {
|
|
416
|
+
const keys = new Set();
|
|
417
|
+
for (const hook of CONTRACT.hooks) {
|
|
418
|
+
for (const dotPath of hook.requiredContextSlice) {
|
|
419
|
+
keys.add(dotPath.split(".")[0]);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return [...keys];
|
|
423
|
+
}
|
|
14
424
|
|
|
15
|
-
export
|
|
16
|
-
export const isHookAllowed = cjs.isHookAllowed;
|
|
17
|
-
export const requiredContextKeys = cjs.requiredContextKeys;
|
|
425
|
+
export { CONTRACT, isHookAllowed, requiredContextKeys };
|
package/dist/hooks.js
CHANGED
|
@@ -66,7 +66,7 @@ function useWidgetContextOrThrow(hookName) {
|
|
|
66
66
|
if (ctx == null) {
|
|
67
67
|
throw new Error(
|
|
68
68
|
`${hookName} must be used inside a WidgetContextProvider. ` +
|
|
69
|
-
`The host (Studio, Player, or exported app) is responsible for mounting it
|
|
69
|
+
`The host (Studio, Player, or exported app) is responsible for mounting it.`,
|
|
70
70
|
);
|
|
71
71
|
}
|
|
72
72
|
return ctx;
|
|
@@ -86,12 +86,17 @@ function toDatastoreError(err) {
|
|
|
86
86
|
? err.response.status
|
|
87
87
|
: null;
|
|
88
88
|
const bodyMessage =
|
|
89
|
-
err &&
|
|
89
|
+
err &&
|
|
90
|
+
err.response &&
|
|
91
|
+
err.response.data &&
|
|
92
|
+
typeof err.response.data.error === "string"
|
|
90
93
|
? err.response.data.error
|
|
91
94
|
: null;
|
|
92
95
|
const fallbackMessage =
|
|
93
96
|
bodyMessage ||
|
|
94
|
-
(err && typeof err.message === "string"
|
|
97
|
+
(err && typeof err.message === "string"
|
|
98
|
+
? err.message
|
|
99
|
+
: "Datastore call failed");
|
|
95
100
|
let code = "INTERNAL";
|
|
96
101
|
if (status === 400 || status === 422) code = "VALIDATION";
|
|
97
102
|
else if (status === 409) code = "CONSTRAINT_VIOLATION";
|
|
@@ -100,7 +105,12 @@ function toDatastoreError(err) {
|
|
|
100
105
|
// Surface a structured fieldErrors map when the server emitted one
|
|
101
106
|
// (record.controller.js shapes 422 bodies as `{ errors: [{ field, code }] }`).
|
|
102
107
|
let fieldErrors;
|
|
103
|
-
if (
|
|
108
|
+
if (
|
|
109
|
+
err &&
|
|
110
|
+
err.response &&
|
|
111
|
+
err.response.data &&
|
|
112
|
+
Array.isArray(err.response.data.errors)
|
|
113
|
+
) {
|
|
104
114
|
const map = {};
|
|
105
115
|
for (const entry of err.response.data.errors) {
|
|
106
116
|
if (entry && typeof entry.field === "string") {
|
|
@@ -132,7 +142,9 @@ function toDatastoreError(err) {
|
|
|
132
142
|
export function useDatastoreQuery(table, query) {
|
|
133
143
|
const ctx = useWidgetContextOrThrow("useDatastoreQuery");
|
|
134
144
|
if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
|
|
135
|
-
throw new Error(
|
|
145
|
+
throw new Error(
|
|
146
|
+
"useDatastoreQuery: host did not inject a datastore client",
|
|
147
|
+
);
|
|
136
148
|
}
|
|
137
149
|
const [data, setData] = useState([]);
|
|
138
150
|
const [loading, setLoading] = useState(Boolean(table));
|
|
@@ -216,7 +228,9 @@ export function useDatastoreQuery(table, query) {
|
|
|
216
228
|
export function useDatastoreMutation(table) {
|
|
217
229
|
const ctx = useWidgetContextOrThrow("useDatastoreMutation");
|
|
218
230
|
if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
|
|
219
|
-
throw new Error(
|
|
231
|
+
throw new Error(
|
|
232
|
+
"useDatastoreMutation: host did not inject a datastore client",
|
|
233
|
+
);
|
|
220
234
|
}
|
|
221
235
|
const ns = ctx.datastore.records(table);
|
|
222
236
|
return {
|
|
@@ -292,7 +306,7 @@ export function useI18n() {
|
|
|
292
306
|
}
|
|
293
307
|
return typeof fallback === "string" ? fallback : key;
|
|
294
308
|
},
|
|
295
|
-
[hostT]
|
|
309
|
+
[hostT],
|
|
296
310
|
);
|
|
297
311
|
return { t, locale };
|
|
298
312
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -42,7 +42,11 @@ export interface WidgetPropertyDef {
|
|
|
42
42
|
enum?: Array<{ value: unknown; label: string }>;
|
|
43
43
|
items?: WidgetPropertyDef;
|
|
44
44
|
properties?: Record<string, WidgetPropertyDef>;
|
|
45
|
-
ui?: {
|
|
45
|
+
ui?: {
|
|
46
|
+
widget?: "textarea" | "slider" | "code";
|
|
47
|
+
group?: string;
|
|
48
|
+
order?: number;
|
|
49
|
+
};
|
|
46
50
|
validation?: { min?: number; max?: number; pattern?: string };
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -181,7 +185,10 @@ export interface WidgetContext<TProps = unknown> {
|
|
|
181
185
|
};
|
|
182
186
|
datastore: unknown; // typed by @colixsystems/datastore-client
|
|
183
187
|
events: { emit(eventName: string, payload?: unknown): void };
|
|
184
|
-
i18n: {
|
|
188
|
+
i18n: {
|
|
189
|
+
locale: string;
|
|
190
|
+
t(key: string, vars?: Record<string, unknown>): string;
|
|
191
|
+
};
|
|
185
192
|
platform: "web" | "native";
|
|
186
193
|
logger: {
|
|
187
194
|
debug: (...args: unknown[]) => void;
|
|
@@ -226,16 +233,16 @@ export function defineWidget<TProps = Record<string, unknown>>(opts: {
|
|
|
226
233
|
}): WidgetModule<TProps>;
|
|
227
234
|
|
|
228
235
|
export function validateManifest(
|
|
229
|
-
manifest: unknown
|
|
236
|
+
manifest: unknown,
|
|
230
237
|
): { ok: true } | { ok: false; errors: string[] };
|
|
231
238
|
|
|
232
239
|
export function validatePropertySchema(
|
|
233
|
-
schema: unknown
|
|
240
|
+
schema: unknown,
|
|
234
241
|
): { ok: true } | { ok: false; errors: string[] };
|
|
235
242
|
|
|
236
243
|
export function validateProps<T = Record<string, unknown>>(
|
|
237
244
|
schema: WidgetPropertySchema,
|
|
238
|
-
props: unknown
|
|
245
|
+
props: unknown,
|
|
239
246
|
): { ok: true; value: T } | { ok: false; errors: string[] };
|
|
240
247
|
|
|
241
248
|
export interface Query {
|
|
@@ -260,16 +267,14 @@ export interface MutationApi<T> {
|
|
|
260
267
|
|
|
261
268
|
export function useDatastoreQuery<T = unknown>(
|
|
262
269
|
table: string,
|
|
263
|
-
query?: Query
|
|
270
|
+
query?: Query,
|
|
264
271
|
): QueryResult<T>;
|
|
265
272
|
|
|
266
273
|
export function useDatastoreMutation<T = unknown>(
|
|
267
|
-
table: string
|
|
274
|
+
table: string,
|
|
268
275
|
): MutationApi<T>;
|
|
269
276
|
|
|
270
|
-
export function useWidgetEvent(
|
|
271
|
-
name: string
|
|
272
|
-
): (payload?: unknown) => void;
|
|
277
|
+
export function useWidgetEvent(name: string): (payload?: unknown) => void;
|
|
273
278
|
|
|
274
279
|
export function useTheme(): ThemeTokens;
|
|
275
280
|
|
|
@@ -291,10 +296,14 @@ export class DatastoreError extends Error {
|
|
|
291
296
|
| "NOT_FOUND"
|
|
292
297
|
| "INTERNAL";
|
|
293
298
|
fieldErrors?: Record<string, string>;
|
|
294
|
-
constructor(
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
299
|
+
constructor(
|
|
300
|
+
code: DatastoreError["code"],
|
|
301
|
+
message: string,
|
|
302
|
+
opts?: {
|
|
303
|
+
fieldErrors?: Record<string, string>;
|
|
304
|
+
cause?: unknown;
|
|
305
|
+
},
|
|
306
|
+
);
|
|
298
307
|
}
|
|
299
308
|
|
|
300
309
|
export function WidgetContextProvider(props: {
|
|
@@ -302,12 +311,21 @@ export function WidgetContextProvider(props: {
|
|
|
302
311
|
children?: ReactNode;
|
|
303
312
|
}): JSX.Element;
|
|
304
313
|
|
|
305
|
-
// Primitives —
|
|
314
|
+
// Primitives — re-exported from `react-native` (web build aliases to
|
|
315
|
+
// `react-native-web`). Typed as opaque components here; widget authors
|
|
316
|
+
// targeting TypeScript should install `@types/react-native` for full
|
|
317
|
+
// prop typing.
|
|
306
318
|
export const Text: any;
|
|
307
319
|
export const View: any;
|
|
308
320
|
export const Pressable: any;
|
|
309
321
|
export const Image: any;
|
|
310
322
|
export const ScrollView: any;
|
|
323
|
+
export const TextInput: any;
|
|
324
|
+
export const FlatList: any;
|
|
325
|
+
export const SectionList: any;
|
|
326
|
+
export const ActivityIndicator: any;
|
|
327
|
+
export const Switch: any;
|
|
328
|
+
export const StyleSheet: any;
|
|
311
329
|
|
|
312
330
|
// Linter
|
|
313
331
|
export interface LintFinding {
|
|
@@ -375,7 +393,14 @@ export interface AiWidgetContract {
|
|
|
375
393
|
readonly manifestPlatforms: ReadonlyArray<string>;
|
|
376
394
|
readonly themeTokens: ThemeTokens;
|
|
377
395
|
readonly widgetContextShape: Readonly<
|
|
378
|
-
Record<
|
|
396
|
+
Record<
|
|
397
|
+
string,
|
|
398
|
+
{
|
|
399
|
+
description: string;
|
|
400
|
+
required: boolean;
|
|
401
|
+
fields: Record<string, unknown>;
|
|
402
|
+
}
|
|
403
|
+
>
|
|
379
404
|
>;
|
|
380
405
|
readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
|
|
381
406
|
readonly bannedApis: ReadonlyArray<ContractBannedApi>;
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,18 @@ export {
|
|
|
14
14
|
useTheme,
|
|
15
15
|
useI18n,
|
|
16
16
|
} from "./hooks.js";
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
Text,
|
|
19
|
+
View,
|
|
20
|
+
Pressable,
|
|
21
|
+
Image,
|
|
22
|
+
ScrollView,
|
|
23
|
+
TextInput,
|
|
24
|
+
FlatList,
|
|
25
|
+
SectionList,
|
|
26
|
+
ActivityIndicator,
|
|
27
|
+
Switch,
|
|
28
|
+
StyleSheet,
|
|
29
|
+
} from "./primitives.js";
|
|
18
30
|
export { lintSource, bannedIdentifiers } from "./linter.js";
|
|
19
31
|
export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
|
package/dist/index.native.js
CHANGED
|
@@ -14,6 +14,18 @@ export {
|
|
|
14
14
|
useTheme,
|
|
15
15
|
useI18n,
|
|
16
16
|
} from "./hooks.js";
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
Text,
|
|
19
|
+
View,
|
|
20
|
+
Pressable,
|
|
21
|
+
Image,
|
|
22
|
+
ScrollView,
|
|
23
|
+
TextInput,
|
|
24
|
+
FlatList,
|
|
25
|
+
SectionList,
|
|
26
|
+
ActivityIndicator,
|
|
27
|
+
Switch,
|
|
28
|
+
StyleSheet,
|
|
29
|
+
} from "./primitives.native.js";
|
|
18
30
|
export { lintSource, bannedIdentifiers } from "./linter.js";
|
|
19
31
|
export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
|
package/dist/linter.cjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// CommonJS-flavoured copy of the linter so backend services (CJS runtime)
|
|
2
|
+
// can `require()` it directly without dynamic-import gymnastics.
|
|
3
|
+
//
|
|
4
|
+
// The ESM `linter.js` is the source-of-truth. This file MUST mirror its
|
|
5
|
+
// rule set byte-for-byte — the contract test in packages/widget-sdk
|
|
6
|
+
// asserts the two are behaviour-equivalent. When you edit one, edit the
|
|
7
|
+
// other in the same PR. (Pattern matches `contract.cjs` / `contract.js`.)
|
|
8
|
+
//
|
|
9
|
+
// The SDK build script copies both into `dist/`.
|
|
10
|
+
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const { CONTRACT } = require("./contract.cjs");
|
|
14
|
+
|
|
15
|
+
function _ruleForIdentifier(identifier, reason) {
|
|
16
|
+
const id = identifier;
|
|
17
|
+
if (id === "Function") {
|
|
18
|
+
return {
|
|
19
|
+
id: "no-function-constructor",
|
|
20
|
+
label: `${reason} (banned: Function() constructor)`,
|
|
21
|
+
pattern: /(^|[^A-Za-z0-9_$.])Function\s*\(/,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (id === "new Function") {
|
|
25
|
+
return {
|
|
26
|
+
id: "no-new-function",
|
|
27
|
+
label: `${reason} (banned: new Function())`,
|
|
28
|
+
pattern: /\bnew\s+Function\s*\(/,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (id === "import(") {
|
|
32
|
+
return {
|
|
33
|
+
id: "no-dynamic-import",
|
|
34
|
+
label: `${reason} (banned: dynamic import())`,
|
|
35
|
+
pattern: /(^|[^A-Za-z0-9_$.])import\s*\(/,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (id === "eval") {
|
|
39
|
+
return {
|
|
40
|
+
id: "no-eval",
|
|
41
|
+
label: `${reason} (banned: eval)`,
|
|
42
|
+
pattern: /\beval\s*\(/,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
46
|
+
return {
|
|
47
|
+
id: `no-${id.toLowerCase()}`,
|
|
48
|
+
label: `${reason} (banned: ${id})`,
|
|
49
|
+
pattern: new RegExp(`\\b${escaped}\\b`),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
54
|
+
_ruleForIdentifier(b.identifier, b.reason),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const EXTRA_RULES = [
|
|
58
|
+
{
|
|
59
|
+
id: "no-auth-store-import",
|
|
60
|
+
label: "widgets must not import the host's auth store",
|
|
61
|
+
pattern: /useAuthStore/,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "no-axios-import",
|
|
65
|
+
label:
|
|
66
|
+
"widgets must not import axios directly; use the injected datastore client",
|
|
67
|
+
pattern: /from\s+['"]axios['"]|require\s*\(\s*['"]axios['"]\s*\)/,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const RULES = [...CONTRACT_RULES, ...EXTRA_RULES];
|
|
72
|
+
|
|
73
|
+
function bannedIdentifiers() {
|
|
74
|
+
return CONTRACT.bannedApis.map((b) => b.identifier);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function lintSource(source) {
|
|
78
|
+
if (typeof source !== "string") {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
findings: [
|
|
82
|
+
{ rule: "input", label: "source must be a string", line: 0, snippet: "" },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const findings = [];
|
|
87
|
+
const lines = source.split(/\r?\n/);
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const line = lines[i];
|
|
90
|
+
for (const rule of RULES) {
|
|
91
|
+
if (rule.pattern.test(line)) {
|
|
92
|
+
findings.push({
|
|
93
|
+
rule: rule.id,
|
|
94
|
+
label: rule.label,
|
|
95
|
+
line: i + 1,
|
|
96
|
+
snippet: line.trim().slice(0, 200),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { ok: findings.length === 0, findings };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { lintSource, bannedIdentifiers };
|
package/dist/manifest.js
CHANGED
|
@@ -1,15 +1,144 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// consumers. This file is a thin re-export so ESM imports keep working
|
|
7
|
-
// unchanged.
|
|
1
|
+
// CommonJS mirror of manifest.js. Lets backend services (CJS) call
|
|
2
|
+
// `validateManifest(...)` without a dynamic-import detour. The two files
|
|
3
|
+
// share their constants by mutual import via createRequire — manifest.js
|
|
4
|
+
// re-exports the same functions defined here so there is exactly one
|
|
5
|
+
// implementation.
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
const VALID_CATEGORIES = new Set([
|
|
8
|
+
"input", "display", "layout", "data", "media", "communication", "custom",
|
|
9
|
+
"INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "CUSTOM",
|
|
10
|
+
]);
|
|
11
|
+
const VALID_PLATFORMS = new Set(["web", "native"]);
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
function canonicalCategory(c) {
|
|
14
|
+
if (typeof c !== "string") return null;
|
|
15
|
+
const upper = c.toUpperCase();
|
|
16
|
+
return [
|
|
17
|
+
"INPUT",
|
|
18
|
+
"DISPLAY",
|
|
19
|
+
"LAYOUT",
|
|
20
|
+
"DATA",
|
|
21
|
+
"MEDIA",
|
|
22
|
+
"COMMUNICATION",
|
|
23
|
+
"CUSTOM",
|
|
24
|
+
].includes(upper)
|
|
25
|
+
? upper
|
|
26
|
+
: null;
|
|
27
|
+
}
|
|
13
28
|
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
30
|
+
const SEMVER_RANGE_RE = /^[\^~>=<]*\s*\d+\.\d+\.\d+/;
|
|
31
|
+
const ID_RE = /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/;
|
|
32
|
+
|
|
33
|
+
function isNonEmptyString(v) {
|
|
34
|
+
return typeof v === "string" && v.length > 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pushIf(errors, cond, msg) {
|
|
38
|
+
if (!cond) errors.push(msg);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateManifest(m) {
|
|
42
|
+
const errors = [];
|
|
43
|
+
if (m === null || typeof m !== "object") {
|
|
44
|
+
return { ok: false, errors: ["manifest must be an object"] };
|
|
45
|
+
}
|
|
46
|
+
const manifest = m;
|
|
47
|
+
|
|
48
|
+
pushIf(errors, isNonEmptyString(manifest.id), "manifest.id must be a non-empty string");
|
|
49
|
+
if (isNonEmptyString(manifest.id)) {
|
|
50
|
+
pushIf(
|
|
51
|
+
errors,
|
|
52
|
+
ID_RE.test(manifest.id),
|
|
53
|
+
"manifest.id must be reverse-DNS, e.g. com.acme.charts.barchart",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pushIf(errors, isNonEmptyString(manifest.name), "manifest.name must be a non-empty string");
|
|
58
|
+
|
|
59
|
+
pushIf(errors, isNonEmptyString(manifest.version), "manifest.version must be a non-empty string");
|
|
60
|
+
if (isNonEmptyString(manifest.version)) {
|
|
61
|
+
pushIf(errors, SEMVER_RE.test(manifest.version), "manifest.version must be a valid semver");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pushIf(
|
|
65
|
+
errors,
|
|
66
|
+
typeof manifest.category === "string" && VALID_CATEGORIES.has(manifest.category),
|
|
67
|
+
`manifest.category must be one of ${[...VALID_CATEGORIES].join(", ")}`,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
pushIf(errors, isNonEmptyString(manifest.icon), "manifest.icon must be a non-empty string");
|
|
71
|
+
pushIf(errors, isNonEmptyString(manifest.description), "manifest.description must be a non-empty string");
|
|
72
|
+
|
|
73
|
+
if (manifest.author === null || typeof manifest.author !== "object") {
|
|
74
|
+
errors.push("manifest.author must be an object with a name");
|
|
75
|
+
} else {
|
|
76
|
+
const author = manifest.author;
|
|
77
|
+
pushIf(errors, isNonEmptyString(author.name), "manifest.author.name must be a non-empty string");
|
|
78
|
+
if (author.url !== undefined) {
|
|
79
|
+
pushIf(errors, isNonEmptyString(author.url), "manifest.author.url must be a string");
|
|
80
|
+
}
|
|
81
|
+
if (author.email !== undefined) {
|
|
82
|
+
pushIf(errors, isNonEmptyString(author.email), "manifest.author.email must be a string");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!Array.isArray(manifest.supportedPlatforms) || manifest.supportedPlatforms.length === 0) {
|
|
87
|
+
errors.push("manifest.supportedPlatforms must be a non-empty array");
|
|
88
|
+
} else {
|
|
89
|
+
for (const p of manifest.supportedPlatforms) {
|
|
90
|
+
if (!VALID_PLATFORMS.has(p)) {
|
|
91
|
+
errors.push(`manifest.supportedPlatforms contains invalid value "${p}"`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pushIf(
|
|
97
|
+
errors,
|
|
98
|
+
isNonEmptyString(manifest.minAppStudioVersion),
|
|
99
|
+
"manifest.minAppStudioVersion must be a non-empty string",
|
|
100
|
+
);
|
|
101
|
+
if (isNonEmptyString(manifest.minAppStudioVersion)) {
|
|
102
|
+
pushIf(
|
|
103
|
+
errors,
|
|
104
|
+
SEMVER_RANGE_RE.test(manifest.minAppStudioVersion),
|
|
105
|
+
"manifest.minAppStudioVersion must be a semver range, e.g. >=2.4.0",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!Array.isArray(manifest.requestedScopes)) {
|
|
110
|
+
errors.push("manifest.requestedScopes must be an array (use [] for none)");
|
|
111
|
+
} else {
|
|
112
|
+
for (const s of manifest.requestedScopes) {
|
|
113
|
+
if (!isNonEmptyString(s)) {
|
|
114
|
+
errors.push("manifest.requestedScopes entries must be non-empty strings");
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (manifest.propertySchema === null || typeof manifest.propertySchema !== "object") {
|
|
121
|
+
errors.push("manifest.propertySchema must be an object");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!Array.isArray(manifest.events)) {
|
|
125
|
+
errors.push("manifest.events must be an array (use [] for none)");
|
|
126
|
+
} else {
|
|
127
|
+
for (const e of manifest.events) {
|
|
128
|
+
if (e === null || typeof e !== "object") {
|
|
129
|
+
errors.push("manifest.events entries must be objects");
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (!isNonEmptyString(e.name)) {
|
|
133
|
+
errors.push("manifest.events[].name must be a non-empty string");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export {
|
|
142
|
+
validateManifest,
|
|
143
|
+
canonicalCategory,
|
|
144
|
+
};
|
package/dist/primitives.js
CHANGED
|
@@ -1,51 +1,35 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function Image({ source, style, alt, ...rest }) {
|
|
40
|
-
const src = typeof source === "string" ? source : source?.uri;
|
|
41
|
-
return el("img", { src, alt: alt ?? "", style: asStyle(style), ...rest });
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function ScrollView({ children, style, horizontal, ...rest }) {
|
|
45
|
-
const merged = {
|
|
46
|
-
overflowX: horizontal ? "auto" : "hidden",
|
|
47
|
-
overflowY: horizontal ? "hidden" : "auto",
|
|
48
|
-
...(asStyle(style) || {}),
|
|
49
|
-
};
|
|
50
|
-
return el("div", { style: merged, ...rest }, children);
|
|
51
|
-
}
|
|
1
|
+
// REQ-WSDK-RN-WEB: the SDK ships ONE primitive surface. On the web
|
|
2
|
+
// (Studio + Player) the host's bundler aliases `react-native` to
|
|
3
|
+
// `react-native-web` (see `frontend/vite.config.js`), which renders the
|
|
4
|
+
// same React Native API as DOM elements. The exported Expo app's Metro
|
|
5
|
+
// bundler resolves `react-native` to the real library via the SDK
|
|
6
|
+
// package.json `react-native` field — pointing at
|
|
7
|
+
// `primitives.native.js`, which mirrors this file.
|
|
8
|
+
//
|
|
9
|
+
// The previous hand-written DOM wrappers (one function per primitive,
|
|
10
|
+
// two implementations to keep in sync) are gone: the React Native API
|
|
11
|
+
// is the contract, react-native-web does the platform mapping for us,
|
|
12
|
+
// and adding a new primitive (FlatList, ActivityIndicator, …) becomes
|
|
13
|
+
// a one-line re-export rather than a paired implementation.
|
|
14
|
+
//
|
|
15
|
+
// Authors continue to import these from `@colixsystems/widget-sdk` — the
|
|
16
|
+
// SDK's index re-exports them under the names we document in CONTRACT.
|
|
17
|
+
// The static analyzer's allowedBareImports still rejects direct
|
|
18
|
+
// `react-native` imports; the SDK is the single entry point.
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
Text,
|
|
22
|
+
View,
|
|
23
|
+
Pressable,
|
|
24
|
+
Image,
|
|
25
|
+
ScrollView,
|
|
26
|
+
TextInput,
|
|
27
|
+
// Additional cross-platform primitives the AI agent + handwritten
|
|
28
|
+
// widgets commonly reach for. All work in both react-native-web and
|
|
29
|
+
// native react-native without per-platform code.
|
|
30
|
+
FlatList,
|
|
31
|
+
SectionList,
|
|
32
|
+
ActivityIndicator,
|
|
33
|
+
Switch,
|
|
34
|
+
StyleSheet,
|
|
35
|
+
} from "react-native";
|
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// REQ-WSDK-RN-WEB: native primitive re-exports. Metro picks this entry
|
|
2
|
+
// via the `react-native` field in package.json. Web mirror in
|
|
3
|
+
// `./primitives.js` re-exports from the SAME `react-native` module —
|
|
4
|
+
// the frontend's Vite alias rewrites it to `react-native-web` for
|
|
5
|
+
// browser builds, so the two files only differ in which `react-native`
|
|
6
|
+
// resolves at bundle time. Keep the export list in lockstep with
|
|
7
|
+
// `./primitives.js`.
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
export {
|
|
10
|
+
Text,
|
|
11
|
+
View,
|
|
12
|
+
Pressable,
|
|
13
|
+
Image,
|
|
14
|
+
ScrollView,
|
|
15
|
+
TextInput,
|
|
16
|
+
FlatList,
|
|
17
|
+
SectionList,
|
|
18
|
+
ActivityIndicator,
|
|
19
|
+
Switch,
|
|
20
|
+
StyleSheet,
|
|
21
|
+
} from "react-native";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": ">=18.0.0",
|
|
54
|
-
"react-native": "*"
|
|
54
|
+
"react-native": "*",
|
|
55
|
+
"react-native-web": ">=0.19.0"
|
|
55
56
|
},
|
|
56
57
|
"peerDependenciesMeta": {
|
|
57
58
|
"react": {
|
|
@@ -59,6 +60,9 @@
|
|
|
59
60
|
},
|
|
60
61
|
"react-native": {
|
|
61
62
|
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"react-native-web": {
|
|
65
|
+
"optional": true
|
|
62
66
|
}
|
|
63
67
|
},
|
|
64
68
|
"keywords": [
|