@colixsystems/widget-sdk 0.3.0 → 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 +40 -4
- package/dist/_theme-tokens.js +10 -0
- package/dist/contract.cjs +433 -0
- package/dist/contract.js +425 -0
- package/dist/hooks.js +259 -23
- package/dist/index.d.ts +122 -12
- package/dist/index.js +16 -2
- package/dist/index.native.js +16 -2
- package/dist/linter.cjs +104 -0
- package/dist/linter.js +72 -24
- package/dist/manifest.cjs +144 -0
- package/dist/manifest.js +47 -36
- package/dist/primitives.js +35 -51
- package/dist/primitives.native.js +20 -10
- package/package.json +13 -3
package/dist/hooks.js
CHANGED
|
@@ -1,15 +1,58 @@
|
|
|
1
|
-
// Widget hooks per docs/architecture/widget-marketplace.md §3.1
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Widget hooks per docs/architecture/widget-marketplace.md §3.1 and the
|
|
2
|
+
// CONTRACT in ./contract.js (the single source of truth — when a hook's
|
|
3
|
+
// signature or return shape changes, update both this file and the
|
|
4
|
+
// CONTRACT entry in lockstep).
|
|
5
|
+
//
|
|
6
|
+
// Each hook reads from the host-provided WidgetContext via React context,
|
|
7
|
+
// then delegates to the host implementation. The hosts (Studio, Player,
|
|
8
|
+
// exported app) own the runtime semantics — this file is the SDK surface
|
|
9
|
+
// widgets call.
|
|
5
10
|
//
|
|
6
11
|
// This file avoids JSX so it can ship as a plain .js without a transform.
|
|
7
12
|
|
|
8
|
-
import React, {
|
|
13
|
+
import React, {
|
|
14
|
+
createContext,
|
|
15
|
+
useContext,
|
|
16
|
+
useCallback,
|
|
17
|
+
useEffect,
|
|
18
|
+
useRef,
|
|
19
|
+
useState,
|
|
20
|
+
} from "react";
|
|
9
21
|
|
|
10
22
|
/** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
|
|
11
23
|
const HostWidgetContext = createContext(null);
|
|
12
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Structured error thrown by `useDatastoreMutation` callbacks (and surfaced
|
|
27
|
+
* by `useDatastoreQuery` in its `error` slot). Carries a stable `code` so
|
|
28
|
+
* widgets can branch on the error class without parsing axios message
|
|
29
|
+
* strings.
|
|
30
|
+
*
|
|
31
|
+
* `code` is one of:
|
|
32
|
+
* - "VALIDATION" — 400 / 422 from the datastore
|
|
33
|
+
* - "CONSTRAINT_VIOLATION" — 409 from the datastore
|
|
34
|
+
* - "FORBIDDEN" — 403 from the datastore
|
|
35
|
+
* - "NOT_FOUND" — 404 from the datastore
|
|
36
|
+
* - "INTERNAL" — anything else (network, 5xx, axios timeout)
|
|
37
|
+
*
|
|
38
|
+
* `fieldErrors` (when present) is a flat map of field name to per-field
|
|
39
|
+
* error message, populated when the datastore returned a structured
|
|
40
|
+
* 400/422 payload with `errors: [{ field, code }, ...]`.
|
|
41
|
+
*/
|
|
42
|
+
export class DatastoreError extends Error {
|
|
43
|
+
constructor(code, message, opts) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "DatastoreError";
|
|
46
|
+
this.code = code;
|
|
47
|
+
if (opts && opts.fieldErrors && typeof opts.fieldErrors === "object") {
|
|
48
|
+
this.fieldErrors = opts.fieldErrors;
|
|
49
|
+
}
|
|
50
|
+
if (opts && opts.cause) {
|
|
51
|
+
this.cause = opts.cause;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
13
56
|
/**
|
|
14
57
|
* Wraps children with the host-provided WidgetContext.
|
|
15
58
|
* The host (Studio/Player/native shell) builds the value and renders this provider.
|
|
@@ -23,54 +66,247 @@ function useWidgetContextOrThrow(hookName) {
|
|
|
23
66
|
if (ctx == null) {
|
|
24
67
|
throw new Error(
|
|
25
68
|
`${hookName} must be used inside a WidgetContextProvider. ` +
|
|
26
|
-
`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.`,
|
|
27
70
|
);
|
|
28
71
|
}
|
|
29
72
|
return ctx;
|
|
30
73
|
}
|
|
31
74
|
|
|
32
|
-
//
|
|
33
|
-
|
|
75
|
+
// --------------------------------------------------------------- helpers
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Coerce an arbitrary thrown value (axios error, plain Error, string) into
|
|
79
|
+
* a DatastoreError with a stable `.code`. Reads `error.response.status` if
|
|
80
|
+
* present (axios shape) and falls back to inspecting the message string.
|
|
81
|
+
*/
|
|
82
|
+
function toDatastoreError(err) {
|
|
83
|
+
if (err instanceof DatastoreError) return err;
|
|
84
|
+
const status =
|
|
85
|
+
err && err.response && typeof err.response.status === "number"
|
|
86
|
+
? err.response.status
|
|
87
|
+
: null;
|
|
88
|
+
const bodyMessage =
|
|
89
|
+
err &&
|
|
90
|
+
err.response &&
|
|
91
|
+
err.response.data &&
|
|
92
|
+
typeof err.response.data.error === "string"
|
|
93
|
+
? err.response.data.error
|
|
94
|
+
: null;
|
|
95
|
+
const fallbackMessage =
|
|
96
|
+
bodyMessage ||
|
|
97
|
+
(err && typeof err.message === "string"
|
|
98
|
+
? err.message
|
|
99
|
+
: "Datastore call failed");
|
|
100
|
+
let code = "INTERNAL";
|
|
101
|
+
if (status === 400 || status === 422) code = "VALIDATION";
|
|
102
|
+
else if (status === 409) code = "CONSTRAINT_VIOLATION";
|
|
103
|
+
else if (status === 403) code = "FORBIDDEN";
|
|
104
|
+
else if (status === 404) code = "NOT_FOUND";
|
|
105
|
+
// Surface a structured fieldErrors map when the server emitted one
|
|
106
|
+
// (record.controller.js shapes 422 bodies as `{ errors: [{ field, code }] }`).
|
|
107
|
+
let fieldErrors;
|
|
108
|
+
if (
|
|
109
|
+
err &&
|
|
110
|
+
err.response &&
|
|
111
|
+
err.response.data &&
|
|
112
|
+
Array.isArray(err.response.data.errors)
|
|
113
|
+
) {
|
|
114
|
+
const map = {};
|
|
115
|
+
for (const entry of err.response.data.errors) {
|
|
116
|
+
if (entry && typeof entry.field === "string") {
|
|
117
|
+
map[entry.field] = entry.message || entry.code || "Invalid value";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(map).length > 0) fieldErrors = map;
|
|
121
|
+
}
|
|
122
|
+
return new DatastoreError(code, fallbackMessage, {
|
|
123
|
+
cause: err,
|
|
124
|
+
fieldErrors,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --------------------------------------------------------------- hooks
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Stateful datastore query hook. Returns { data, loading, error, refetch }.
|
|
132
|
+
*
|
|
133
|
+
* The host's datastore client exposes `records(table).list(query)` which
|
|
134
|
+
* returns a Promise<Record[]>. We hold the result in component state and
|
|
135
|
+
* re-fetch when [table, JSON.stringify(query)] changes. `refetch` re-runs
|
|
136
|
+
* the same call on demand.
|
|
137
|
+
*
|
|
138
|
+
* When `table` is falsy (e.g. the user hasn't bound a `tableRef` property
|
|
139
|
+
* yet), the hook resolves to { data: [], loading: false, error: null,
|
|
140
|
+
* refetch } so the widget can render its empty state without throwing.
|
|
141
|
+
*/
|
|
34
142
|
export function useDatastoreQuery(table, query) {
|
|
35
143
|
const ctx = useWidgetContextOrThrow("useDatastoreQuery");
|
|
36
144
|
if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
|
|
37
|
-
throw new Error(
|
|
145
|
+
throw new Error(
|
|
146
|
+
"useDatastoreQuery: host did not inject a datastore client",
|
|
147
|
+
);
|
|
38
148
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
149
|
+
const [data, setData] = useState([]);
|
|
150
|
+
const [loading, setLoading] = useState(Boolean(table));
|
|
151
|
+
const [error, setError] = useState(null);
|
|
152
|
+
|
|
153
|
+
// Capture the latest table + query in refs so refetch() — a stable
|
|
154
|
+
// callback identity — always reads the current arguments without having
|
|
155
|
+
// to be re-bound on every render.
|
|
156
|
+
//
|
|
157
|
+
// We hold ctx.datastore.records in a ref for the same reason: the
|
|
158
|
+
// Studio / Player / PageRenderer rebuild the WidgetContext value
|
|
159
|
+
// inside their render closure on every render, so `ctx` is a fresh
|
|
160
|
+
// object identity every render. If `doFetch` listed `[ctx]` in its
|
|
161
|
+
// dep array, the callback identity (and therefore the `refetch`
|
|
162
|
+
// surface) would change every render and any widget that put
|
|
163
|
+
// `refetch` in a useEffect dep array would loop forever. The effect
|
|
164
|
+
// that drives the actual initial fetch keeps `[table, queryKey]` so
|
|
165
|
+
// it only re-runs when the bound table or the query payload changes.
|
|
166
|
+
const tableRef = useRef(table);
|
|
167
|
+
const queryRef = useRef(query);
|
|
168
|
+
const recordsRef = useRef(ctx.datastore.records);
|
|
169
|
+
tableRef.current = table;
|
|
170
|
+
queryRef.current = query;
|
|
171
|
+
recordsRef.current = ctx.datastore.records;
|
|
172
|
+
|
|
173
|
+
const runRef = useRef(0);
|
|
174
|
+
|
|
175
|
+
const doFetch = useCallback(async () => {
|
|
176
|
+
const myRun = ++runRef.current;
|
|
177
|
+
const t = tableRef.current;
|
|
178
|
+
if (!t) {
|
|
179
|
+
// No table bound yet — collapse to a stable empty result without
|
|
180
|
+
// a network round-trip.
|
|
181
|
+
setLoading(false);
|
|
182
|
+
setError(null);
|
|
183
|
+
setData([]);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
setLoading(true);
|
|
187
|
+
setError(null);
|
|
188
|
+
try {
|
|
189
|
+
const ns = recordsRef.current(t);
|
|
190
|
+
const rows = await ns.list(queryRef.current);
|
|
191
|
+
// Discard the result if a newer fetch has started since we kicked off.
|
|
192
|
+
if (runRef.current !== myRun) return;
|
|
193
|
+
setData(Array.isArray(rows) ? rows : []);
|
|
194
|
+
setLoading(false);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (runRef.current !== myRun) return;
|
|
197
|
+
setError(toDatastoreError(err));
|
|
198
|
+
setLoading(false);
|
|
199
|
+
}
|
|
200
|
+
}, []);
|
|
201
|
+
|
|
202
|
+
// Re-run on mount and whenever [table, JSON.stringify(query)] changes.
|
|
203
|
+
const queryKey = (() => {
|
|
204
|
+
try {
|
|
205
|
+
return JSON.stringify(query);
|
|
206
|
+
} catch (_e) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
})();
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
doFetch();
|
|
212
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
213
|
+
}, [table, queryKey]);
|
|
214
|
+
|
|
215
|
+
const refetch = useCallback(async () => {
|
|
216
|
+
await doFetch();
|
|
217
|
+
}, [doFetch]);
|
|
218
|
+
|
|
219
|
+
return { data, loading, error, refetch };
|
|
42
220
|
}
|
|
43
221
|
|
|
44
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Datastore mutation hook. Returns { create, update, delete }, each method
|
|
224
|
+
* returning a Promise. Rejected promises throw a DatastoreError carrying a
|
|
225
|
+
* stable `.code` (`VALIDATION`, `CONSTRAINT_VIOLATION`, `FORBIDDEN`,
|
|
226
|
+
* `NOT_FOUND`, `INTERNAL`) plus an optional `fieldErrors` map.
|
|
227
|
+
*/
|
|
45
228
|
export function useDatastoreMutation(table) {
|
|
46
229
|
const ctx = useWidgetContextOrThrow("useDatastoreMutation");
|
|
47
230
|
if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
|
|
48
|
-
throw new Error(
|
|
231
|
+
throw new Error(
|
|
232
|
+
"useDatastoreMutation: host did not inject a datastore client",
|
|
233
|
+
);
|
|
49
234
|
}
|
|
50
235
|
const ns = ctx.datastore.records(table);
|
|
51
236
|
return {
|
|
52
|
-
create: (values) =>
|
|
53
|
-
|
|
54
|
-
|
|
237
|
+
create: async (values) => {
|
|
238
|
+
try {
|
|
239
|
+
return await ns.create(values);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
throw toDatastoreError(err);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
update: async (id, values) => {
|
|
245
|
+
try {
|
|
246
|
+
return await ns.update(id, values);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
throw toDatastoreError(err);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
delete: async (id) => {
|
|
252
|
+
try {
|
|
253
|
+
return await ns.delete(id);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
throw toDatastoreError(err);
|
|
256
|
+
}
|
|
257
|
+
},
|
|
55
258
|
};
|
|
56
259
|
}
|
|
57
260
|
|
|
58
|
-
|
|
59
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Emit a named widget event through ctx.events.emit. Page-level event
|
|
263
|
+
* bindings (subscribed by the host) decide what happens next.
|
|
264
|
+
*/
|
|
60
265
|
export function useWidgetEvent(name) {
|
|
61
266
|
const ctx = useWidgetContextOrThrow("useWidgetEvent");
|
|
62
267
|
return useCallback((payload) => ctx.events.emit(name, payload), [ctx, name]);
|
|
63
268
|
}
|
|
64
269
|
|
|
65
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Returns the host-provided theme tokens. The host guarantees every field
|
|
272
|
+
* documented in CONTRACT.themeTokens is present (defaults merged with
|
|
273
|
+
* tenant overrides), so widgets read `theme.colors.primary` without
|
|
274
|
+
* optional chaining.
|
|
275
|
+
*/
|
|
66
276
|
export function useTheme() {
|
|
67
277
|
const ctx = useWidgetContextOrThrow("useTheme");
|
|
68
278
|
return ctx.workspace.theme;
|
|
69
279
|
}
|
|
70
280
|
|
|
71
|
-
|
|
72
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
|
|
283
|
+
* the host's translation table and falls back to `fallback ?? key` when
|
|
284
|
+
* the key is missing. The host's i18n.t may or may not honour the two-arg
|
|
285
|
+
* form; we degrade gracefully either way.
|
|
286
|
+
*/
|
|
73
287
|
export function useI18n() {
|
|
74
288
|
const ctx = useWidgetContextOrThrow("useI18n");
|
|
75
|
-
|
|
289
|
+
const i18n = ctx.i18n || {};
|
|
290
|
+
const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
|
|
291
|
+
const hostT = typeof i18n.t === "function" ? i18n.t : null;
|
|
292
|
+
const t = useCallback(
|
|
293
|
+
(key, fallback) => {
|
|
294
|
+
if (typeof key !== "string" || !key) {
|
|
295
|
+
return typeof fallback === "string" ? fallback : "";
|
|
296
|
+
}
|
|
297
|
+
if (hostT) {
|
|
298
|
+
// Try the two-arg form first; if the host's `t` ignores `fallback`
|
|
299
|
+
// and returns the bare key on a miss, swap in the fallback.
|
|
300
|
+
const out = hostT(key, fallback);
|
|
301
|
+
if (typeof out === "string" && out.length > 0 && out !== key) {
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
// Host returned the key (unresolved) or nothing usable.
|
|
305
|
+
return typeof fallback === "string" ? fallback : key;
|
|
306
|
+
}
|
|
307
|
+
return typeof fallback === "string" ? fallback : key;
|
|
308
|
+
},
|
|
309
|
+
[hostT],
|
|
310
|
+
);
|
|
311
|
+
return { t, locale };
|
|
76
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,35 +267,65 @@ 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
|
|
|
276
281
|
export function useI18n(): {
|
|
277
282
|
locale: string;
|
|
278
|
-
t(key: string,
|
|
283
|
+
t(key: string, fallback?: string): string;
|
|
279
284
|
};
|
|
280
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Error class thrown by useDatastoreMutation callbacks (and surfaced by
|
|
288
|
+
* useDatastoreQuery in its `error` slot). The `code` is a stable
|
|
289
|
+
* categorisation widgets can branch on.
|
|
290
|
+
*/
|
|
291
|
+
export class DatastoreError extends Error {
|
|
292
|
+
code:
|
|
293
|
+
| "VALIDATION"
|
|
294
|
+
| "CONSTRAINT_VIOLATION"
|
|
295
|
+
| "FORBIDDEN"
|
|
296
|
+
| "NOT_FOUND"
|
|
297
|
+
| "INTERNAL";
|
|
298
|
+
fieldErrors?: Record<string, string>;
|
|
299
|
+
constructor(
|
|
300
|
+
code: DatastoreError["code"],
|
|
301
|
+
message: string,
|
|
302
|
+
opts?: {
|
|
303
|
+
fieldErrors?: Record<string, string>;
|
|
304
|
+
cause?: unknown;
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
281
309
|
export function WidgetContextProvider(props: {
|
|
282
310
|
value: WidgetContext;
|
|
283
311
|
children?: ReactNode;
|
|
284
312
|
}): JSX.Element;
|
|
285
313
|
|
|
286
|
-
// 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.
|
|
287
318
|
export const Text: any;
|
|
288
319
|
export const View: any;
|
|
289
320
|
export const Pressable: any;
|
|
290
321
|
export const Image: any;
|
|
291
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;
|
|
292
329
|
|
|
293
330
|
// Linter
|
|
294
331
|
export interface LintFinding {
|
|
@@ -301,3 +338,76 @@ export function lintSource(source: string): {
|
|
|
301
338
|
ok: boolean;
|
|
302
339
|
findings: LintFinding[];
|
|
303
340
|
};
|
|
341
|
+
|
|
342
|
+
// --------------------------------------------------------------- CONTRACT
|
|
343
|
+
//
|
|
344
|
+
// Single-source-of-truth contract artefact. See docs/design/ai-widget-contract.md.
|
|
345
|
+
// The runtime value is `Object.freeze`d; the type below describes the
|
|
346
|
+
// public shape consumers can read.
|
|
347
|
+
|
|
348
|
+
export interface ContractHookEntry {
|
|
349
|
+
name: string;
|
|
350
|
+
signature: string;
|
|
351
|
+
returnShape: Record<string, string>;
|
|
352
|
+
requiredContextSlice: string[];
|
|
353
|
+
scopes: string[] | null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface ContractPrimitiveEntry {
|
|
357
|
+
name: string;
|
|
358
|
+
description: string;
|
|
359
|
+
props: Record<
|
|
360
|
+
string,
|
|
361
|
+
{ type: string; required?: boolean; description?: string }
|
|
362
|
+
>;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface ContractManifestField {
|
|
366
|
+
type: string;
|
|
367
|
+
required: boolean;
|
|
368
|
+
description?: string;
|
|
369
|
+
default?: unknown;
|
|
370
|
+
values?: readonly string[];
|
|
371
|
+
example?: unknown;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface ContractBundleShape {
|
|
375
|
+
name: string;
|
|
376
|
+
description: string;
|
|
377
|
+
predicate: string;
|
|
378
|
+
manifestSource: string;
|
|
379
|
+
audience: string;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export interface ContractBannedApi {
|
|
383
|
+
identifier: string;
|
|
384
|
+
reason: string;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface AiWidgetContract {
|
|
388
|
+
readonly version: string;
|
|
389
|
+
readonly hooks: ReadonlyArray<ContractHookEntry>;
|
|
390
|
+
readonly primitives: ReadonlyArray<ContractPrimitiveEntry>;
|
|
391
|
+
readonly manifestSchema: Readonly<Record<string, ContractManifestField>>;
|
|
392
|
+
readonly manifestCategories: ReadonlyArray<string>;
|
|
393
|
+
readonly manifestPlatforms: ReadonlyArray<string>;
|
|
394
|
+
readonly themeTokens: ThemeTokens;
|
|
395
|
+
readonly widgetContextShape: Readonly<
|
|
396
|
+
Record<
|
|
397
|
+
string,
|
|
398
|
+
{
|
|
399
|
+
description: string;
|
|
400
|
+
required: boolean;
|
|
401
|
+
fields: Record<string, unknown>;
|
|
402
|
+
}
|
|
403
|
+
>
|
|
404
|
+
>;
|
|
405
|
+
readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
|
|
406
|
+
readonly bannedApis: ReadonlyArray<ContractBannedApi>;
|
|
407
|
+
readonly allowedBareImports: ReadonlyArray<string>;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export const CONTRACT: AiWidgetContract;
|
|
411
|
+
|
|
412
|
+
export function isHookAllowed(name: string): boolean;
|
|
413
|
+
export function requiredContextKeys(): string[];
|
package/dist/index.js
CHANGED
|
@@ -7,11 +7,25 @@ export { validateManifest, canonicalCategory } from "./manifest.js";
|
|
|
7
7
|
export { validatePropertySchema, validateProps } from "./property-schema.js";
|
|
8
8
|
export {
|
|
9
9
|
WidgetContextProvider,
|
|
10
|
+
DatastoreError,
|
|
10
11
|
useDatastoreQuery,
|
|
11
12
|
useDatastoreMutation,
|
|
12
13
|
useWidgetEvent,
|
|
13
14
|
useTheme,
|
|
14
15
|
useI18n,
|
|
15
16
|
} from "./hooks.js";
|
|
16
|
-
export {
|
|
17
|
-
|
|
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";
|
|
30
|
+
export { lintSource, bannedIdentifiers } from "./linter.js";
|
|
31
|
+
export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
|
package/dist/index.native.js
CHANGED
|
@@ -7,11 +7,25 @@ export { validateManifest, canonicalCategory } from "./manifest.js";
|
|
|
7
7
|
export { validatePropertySchema, validateProps } from "./property-schema.js";
|
|
8
8
|
export {
|
|
9
9
|
WidgetContextProvider,
|
|
10
|
+
DatastoreError,
|
|
10
11
|
useDatastoreQuery,
|
|
11
12
|
useDatastoreMutation,
|
|
12
13
|
useWidgetEvent,
|
|
13
14
|
useTheme,
|
|
14
15
|
useI18n,
|
|
15
16
|
} from "./hooks.js";
|
|
16
|
-
export {
|
|
17
|
-
|
|
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";
|
|
30
|
+
export { lintSource, bannedIdentifiers } from "./linter.js";
|
|
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 };
|