@colixsystems/widget-sdk 0.14.1 → 0.16.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/dist/linter.js CHANGED
@@ -57,18 +57,19 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
57
57
 
58
58
  // Extra rules that don't map 1:1 to a banned identifier in the contract:
59
59
  // host-internal imports that widgets must never touch.
60
+ //
61
+ // REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
62
+ // import list now (`CONTRACT.vettedImports`) — widgets may call third-party
63
+ // APIs directly. Calls to the host's own /api/* surface are blocked at
64
+ // runtime by the WidgetContextProvider's network gate; the soft
65
+ // `no-host-api-url` rule below flags obvious host-URL substrings so
66
+ // authors learn the rule statically.
60
67
  const EXTRA_RULES = [
61
68
  {
62
69
  id: "no-auth-store-import",
63
70
  label: "widgets must not import the host's auth store",
64
71
  pattern: /useAuthStore/,
65
72
  },
66
- {
67
- id: "no-axios-import",
68
- label:
69
- "widgets must not import axios directly; use the injected datastore client",
70
- pattern: /from\s+['"]axios['"]|require\s*\(\s*['"]axios['"]\s*\)/,
71
- },
72
73
  ];
73
74
 
74
75
  const RULES = [...CONTRACT_RULES, ...EXTRA_RULES];
@@ -83,6 +84,158 @@ export function bannedIdentifiers() {
83
84
  return CONTRACT.bannedApis.map((b) => b.identifier);
84
85
  }
85
86
 
87
+ // REQ-WSDK-PLATFORM §3.4, §8: scan the source for every bare import
88
+ // specifier and validate it against `CONTRACT.vettedImports`.
89
+ //
90
+ // Recognised forms (string-level, no AST):
91
+ // import X from "spec"
92
+ // import { … } from "spec"
93
+ // import * as X from "spec"
94
+ // import "spec"
95
+ // const X = require("spec") // CJS — for completeness
96
+ //
97
+ // Relative imports within the bundle (`./foo.js`, `./shared/util.js`) are
98
+ // allowed — REQ-WSDK-PLATFORM §4.2 lets a split-impl widget share helpers
99
+ // between `widget.web.jsx` and `widget.native.jsx`. Parent-directory
100
+ // (`../`) and absolute (`/`, `http(s):`) paths are rejected to keep the
101
+ // bundle scope sealed.
102
+ const IMPORT_SPECIFIER_RE =
103
+ /(?:^|[\s;])(?:import(?:\s+[^"';]+from)?|require)\s*\(?\s*['"]([^'"]+)['"]/g;
104
+
105
+ function _classifySpecifier(spec) {
106
+ if (
107
+ spec.startsWith("./") ||
108
+ (spec.startsWith(".") && !spec.startsWith(".."))
109
+ ) {
110
+ return "relative-ok";
111
+ }
112
+ if (spec.startsWith("../")) return "relative-escape";
113
+ if (
114
+ spec.startsWith("/") ||
115
+ spec.startsWith("http:") ||
116
+ spec.startsWith("https:") ||
117
+ spec.startsWith("blob:") ||
118
+ spec.startsWith("data:")
119
+ ) {
120
+ return "absolute";
121
+ }
122
+ return "bare";
123
+ }
124
+
125
+ function _importRules(source, manifest) {
126
+ const findings = [];
127
+ const allowed = new Map(
128
+ CONTRACT.vettedImports.map((v) => [v.specifier, v]),
129
+ );
130
+ // Track declared `supportedPlatforms` so a widget that claims "web only"
131
+ // doesn't import a native-only package (and vice versa) without the
132
+ // marketplace listing being honest about which platforms ship.
133
+ const declaredPlatforms =
134
+ manifest &&
135
+ Array.isArray(manifest.supportedPlatforms) &&
136
+ manifest.supportedPlatforms.length > 0
137
+ ? new Set(manifest.supportedPlatforms)
138
+ : null;
139
+
140
+ const lines = source.split(/\r?\n/);
141
+ for (let i = 0; i < lines.length; i += 1) {
142
+ const line = lines[i];
143
+ // Reset lastIndex — the regex is /g and shared across lines.
144
+ IMPORT_SPECIFIER_RE.lastIndex = 0;
145
+ let m;
146
+ while ((m = IMPORT_SPECIFIER_RE.exec(line))) {
147
+ const spec = m[1];
148
+ const kind = _classifySpecifier(spec);
149
+ if (kind === "relative-ok") continue;
150
+ if (kind === "relative-escape") {
151
+ findings.push({
152
+ rule: "no-parent-relative-import",
153
+ label: `relative import "${spec}" escapes the bundle (\`../\`) — only sibling-file imports (\`./foo.js\`) are allowed`,
154
+ line: i + 1,
155
+ snippet: line.trim().slice(0, 200),
156
+ });
157
+ continue;
158
+ }
159
+ if (kind === "absolute") {
160
+ findings.push({
161
+ rule: "no-absolute-import",
162
+ label: `absolute import "${spec}" is not allowed — use a vetted bare specifier or a sibling-file (\`./foo.js\`) import`,
163
+ line: i + 1,
164
+ snippet: line.trim().slice(0, 200),
165
+ });
166
+ continue;
167
+ }
168
+ // Bare specifier — validate against the vetted list.
169
+ const entry = allowed.get(spec);
170
+ if (!entry) {
171
+ findings.push({
172
+ rule: "import-not-vetted",
173
+ label:
174
+ `import "${spec}" is not on the vetted package list — see ` +
175
+ `CONTRACT.vettedImports for the current set or open a request to add it`,
176
+ line: i + 1,
177
+ snippet: line.trim().slice(0, 200),
178
+ });
179
+ continue;
180
+ }
181
+ // If the manifest declares supportedPlatforms, every imported
182
+ // specifier must support every declared platform. A widget that
183
+ // imports react-native-maps (native-only) cannot honestly claim
184
+ // supportedPlatforms: ["web", "native"] from a single source file —
185
+ // it must either drop "web" from the manifest, OR move that import
186
+ // into widget.native.jsx so the web bundle doesn't see it.
187
+ if (declaredPlatforms) {
188
+ for (const wanted of declaredPlatforms) {
189
+ if (!entry.platforms.includes(wanted)) {
190
+ findings.push({
191
+ rule: "import-platform-mismatch",
192
+ label:
193
+ `"${spec}" supports [${entry.platforms.join(", ")}] but the ` +
194
+ `manifest claims supportedPlatforms includes "${wanted}". ` +
195
+ `Move this import into widget.${wanted === "web" ? "native" : "web"}.jsx, ` +
196
+ `or drop "${wanted}" from manifest.supportedPlatforms.`,
197
+ line: i + 1,
198
+ snippet: line.trim().slice(0, 200),
199
+ });
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ return findings;
206
+ }
207
+
208
+ // REQ-WSDK-PLATFORM §3.5: soft warning when source contains host-API URL
209
+ // substrings. NOT a hard block — false positives are possible (a widget
210
+ // that happens to call a third-party API also located at `/api`). The
211
+ // marketplace review queue surfaces the warning for a human pass.
212
+ function _hostApiUrlRules(source) {
213
+ const findings = [];
214
+ const patterns = CONTRACT.hostApiUrlPatterns || [];
215
+ const lines = source.split(/\r?\n/);
216
+ for (let i = 0; i < lines.length; i += 1) {
217
+ const line = lines[i];
218
+ for (const needle of patterns) {
219
+ if (line.includes(needle)) {
220
+ findings.push({
221
+ rule: "no-host-api-url",
222
+ severity: "warning",
223
+ label:
224
+ `source contains "${needle}" — calls to the AppStudio host API ` +
225
+ `are blocked at runtime (widgets get no JWT token). Use SDK ` +
226
+ `hooks for workspace data; \`axios\`/\`fetch\` for third-party APIs.`,
227
+ line: i + 1,
228
+ snippet: line.trim().slice(0, 200),
229
+ });
230
+ // Only fire once per line — repeated needle hits on the same line
231
+ // are noise.
232
+ break;
233
+ }
234
+ }
235
+ }
236
+ return findings;
237
+ }
238
+
86
239
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation.
87
240
  //
88
241
  // A widget that calls `useUsers().invite()` / `.deactivate()` /
@@ -197,18 +350,29 @@ function _scopeRules(source, manifest) {
197
350
  /**
198
351
  * Lint a JavaScript source string.
199
352
  *
353
+ * REQ-WSDK-PLATFORM update: findings can now carry a `severity` field
354
+ * (`"error"` | `"warning"`). The `ok` flag is true iff NO `severity:
355
+ * "error"` finding exists — warnings (currently just `no-host-api-url`)
356
+ * surface to the reviewer but do not block publish. Findings without an
357
+ * explicit `severity` are treated as errors for back-compat with existing
358
+ * rules.
359
+ *
200
360
  * @param {string} source
201
- * @param {{ manifest?: { requestedScopes?: string[] } }} [options] — when a
202
- * manifest is supplied, scope-aware rules also fire (see
203
- * `scope-required-for-user-mutation`).
204
- * @returns {{ ok: boolean, findings: Array<{ rule: string, label: string, line: number, snippet: string }> }}
361
+ * @param {{ manifest?: { requestedScopes?: string[], supportedPlatforms?: string[] } }} [options]
362
+ * @returns {{ ok: boolean, findings: Array<{ rule: string, severity?: "error" | "warning", label: string, line: number, snippet: string }> }}
205
363
  */
206
364
  export function lintSource(source, options) {
207
365
  if (typeof source !== "string") {
208
366
  return {
209
367
  ok: false,
210
368
  findings: [
211
- { rule: "input", label: "source must be a string", line: 0, snippet: "" },
369
+ {
370
+ rule: "input",
371
+ severity: "error",
372
+ label: "source must be a string",
373
+ line: 0,
374
+ snippet: "",
375
+ },
212
376
  ],
213
377
  };
214
378
  }
@@ -220,6 +384,7 @@ export function lintSource(source, options) {
220
384
  if (rule.pattern.test(line)) {
221
385
  findings.push({
222
386
  rule: rule.id,
387
+ severity: "error",
223
388
  label: rule.label,
224
389
  line: i + 1,
225
390
  snippet: line.trim().slice(0, 200),
@@ -227,9 +392,24 @@ export function lintSource(source, options) {
227
392
  }
228
393
  }
229
394
  }
395
+ // REQ-WSDK-PLATFORM §3.4: import-specifier validation.
396
+ findings.push(
397
+ ..._importRules(source, options && options.manifest).map((f) => ({
398
+ ...f,
399
+ severity: f.severity || "error",
400
+ })),
401
+ );
402
+ // REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
403
+ findings.push(..._hostApiUrlRules(source));
230
404
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
231
405
  // line-by-line scan so banned-identifier findings stay first in the
232
406
  // output.
233
- findings.push(..._scopeRules(source, options && options.manifest));
234
- return { ok: findings.length === 0, findings };
407
+ findings.push(
408
+ ..._scopeRules(source, options && options.manifest).map((f) => ({
409
+ ...f,
410
+ severity: f.severity || "error",
411
+ })),
412
+ );
413
+ const hasErrors = findings.some((f) => f.severity !== "warning");
414
+ return { ok: !hasErrors, findings };
235
415
  }
@@ -60,3 +60,11 @@ export const StyleSheet = ReactNative.StyleSheet;
60
60
  // the OS hands it off to the system handler. `canOpenURL` reports whether
61
61
  // the URL scheme is registered.
62
62
  export const Linking = ReactNative.Linking;
63
+ // REQ-WSDK-PLATFORM §6 — `<Icon>` wraps lucide-react-native. Same source
64
+ // runs on both platforms; see ./icon.js.
65
+ export { Icon } from "./icon.js";
66
+ // REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` wraps
67
+ // @react-native-community/datetimepicker and normalizes its value to
68
+ // ISO 8601 strings. Same source runs on both platforms; see
69
+ // ./datetimepicker.js.
70
+ export { DateTimePicker } from "./datetimepicker.js";
@@ -20,3 +20,12 @@ export {
20
20
  StyleSheet,
21
21
  Linking,
22
22
  } from "react-native";
23
+
24
+ // REQ-WSDK-PLATFORM §6 — `<Icon>` is implemented in one file that runs on
25
+ // both platforms (lucide-react-native ships a working build for both).
26
+ export { Icon } from "./icon.js";
27
+ // REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` ditto. The underlying
28
+ // @react-native-community/datetimepicker handles its own
29
+ // per-platform rendering; the SDK wrapper just normalizes the value
30
+ // to ISO strings.
31
+ export { DateTimePicker } from "./datetimepicker.js";
package/dist/toast.js ADDED
@@ -0,0 +1,73 @@
1
+ // REQ-WSDK-PLATFORM §6 — `useToast()` hook (web implementation).
2
+ //
3
+ // Surfaces a short auto-dismissing notification. Widget API:
4
+ //
5
+ // const { showToast } = useToast();
6
+ // showToast({ kind: "success" | "error" | "info" | "warning", message: "Saved" });
7
+ //
8
+ // Renderer resolution order:
9
+ // 1. If the host provided `ctx.toast.showToast`, call that. The host
10
+ // owns positioning + theming.
11
+ // 2. Otherwise: dispatch a CustomEvent named "appstudio:widget-toast"
12
+ // on the window with detail `{ kind, message }`. Hosts that want a
13
+ // central toast renderer install a `window.addEventListener` for it.
14
+ // 3. As a last resort, `console.log` the toast so dev work surfaces
15
+ // something even before either of the above is wired.
16
+ //
17
+ // The SDK does NOT render any DOM itself — that responsibility belongs
18
+ // to the host so a single workspace-themed toast component shows the
19
+ // notifications across all widgets.
20
+
21
+ import { useCallback } from "react";
22
+ import { useWidgetContextOrThrow } from "./hooks.js";
23
+
24
+ function _normalizeKind(kind) {
25
+ return kind === "success" || kind === "error" || kind === "warning"
26
+ ? kind
27
+ : "info";
28
+ }
29
+
30
+ export function useToast() {
31
+ const ctx = useWidgetContextOrThrow("useToast");
32
+ const hostToast =
33
+ ctx && ctx.toast && typeof ctx.toast.showToast === "function"
34
+ ? ctx.toast.showToast
35
+ : null;
36
+ const showToast = useCallback(
37
+ (payload) => {
38
+ const opts = payload && typeof payload === "object" ? payload : {};
39
+ const kind = _normalizeKind(opts.kind);
40
+ const message = typeof opts.message === "string" ? opts.message : "";
41
+ if (!message) return;
42
+ if (hostToast) {
43
+ try {
44
+ hostToast({ kind, message });
45
+ return;
46
+ } catch (err) {
47
+ // Fall through to the event + console fallback rather than
48
+ // crashing the widget.
49
+ if (typeof console !== "undefined" && console.warn) {
50
+ console.warn("[useToast] host toast handler threw:", err);
51
+ }
52
+ }
53
+ }
54
+ if (
55
+ typeof window !== "undefined" &&
56
+ typeof window.dispatchEvent === "function" &&
57
+ typeof CustomEvent === "function"
58
+ ) {
59
+ window.dispatchEvent(
60
+ new CustomEvent("appstudio:widget-toast", {
61
+ detail: { kind, message },
62
+ }),
63
+ );
64
+ return;
65
+ }
66
+ if (typeof console !== "undefined" && console.log) {
67
+ console.log(`[toast:${kind}] ${message}`);
68
+ }
69
+ },
70
+ [hostToast],
71
+ );
72
+ return { showToast };
73
+ }
@@ -0,0 +1,46 @@
1
+ // REQ-WSDK-PLATFORM §6 — `useToast()` hook (native implementation).
2
+ //
3
+ // Same API as the web counterpart (./toast.js). Native has no
4
+ // `window.dispatchEvent` fallback — if the host doesn't provide
5
+ // `ctx.toast.showToast`, the hook falls back to `console.log` so the
6
+ // widget at least surfaces something during dev.
7
+
8
+ import { useCallback } from "react";
9
+ import { useWidgetContextOrThrow } from "./hooks.js";
10
+
11
+ function _normalizeKind(kind) {
12
+ return kind === "success" || kind === "error" || kind === "warning"
13
+ ? kind
14
+ : "info";
15
+ }
16
+
17
+ export function useToast() {
18
+ const ctx = useWidgetContextOrThrow("useToast");
19
+ const hostToast =
20
+ ctx && ctx.toast && typeof ctx.toast.showToast === "function"
21
+ ? ctx.toast.showToast
22
+ : null;
23
+ const showToast = useCallback(
24
+ (payload) => {
25
+ const opts = payload && typeof payload === "object" ? payload : {};
26
+ const kind = _normalizeKind(opts.kind);
27
+ const message = typeof opts.message === "string" ? opts.message : "";
28
+ if (!message) return;
29
+ if (hostToast) {
30
+ try {
31
+ hostToast({ kind, message });
32
+ return;
33
+ } catch (err) {
34
+ if (typeof console !== "undefined" && console.warn) {
35
+ console.warn("[useToast] host toast handler threw:", err);
36
+ }
37
+ }
38
+ }
39
+ if (typeof console !== "undefined" && console.log) {
40
+ console.log(`[toast:${kind}] ${message}`);
41
+ }
42
+ },
43
+ [hostToast],
44
+ );
45
+ return { showToast };
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.14.1",
3
+ "version": "0.16.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",