@colixsystems/widget-sdk 0.36.0 → 0.38.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 CHANGED
@@ -26,7 +26,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
26
26
  | **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
27
27
  | **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
28
28
  | **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
29
- | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) then falls back to the raw key. |
29
+ | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
30
30
  | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
31
31
  | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
32
32
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
@@ -47,7 +47,21 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
47
47
 
48
48
  ## Status
49
49
 
50
- `v0.36.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**.
50
+ `v0.38.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**.
51
+
52
+ ### What's new in 0.38.0
53
+
54
+ **Predefined SHARED translation keys (REQ-L10N-SHARED).** The standard strings the default widgets repeat ("Submit", "Cancel", "Save", "Loading…", …) now have a tenant-wide shared namespace `shared.<key>` so an identical string is translated **once** and every widget that uses it inherits the translation.
55
+
56
+ - **New contract field `CONTRACT.sharedTranslationKeys`** — the predefined map (`{ <key>: { en } }`) and the single source the host seeder reads.
57
+ - **New exported helpers** `sharedTranslationPrefix()` / `sharedTranslationKey(key)` / `isSharedTranslationKey(key)` (from `@colixsystems/widget-sdk/contract`).
58
+ - **`useI18n().t(key)` resolution is now three-step**: the per-widget key (`widget.<id>.<key>`) first, then the shared key (`shared.<key>`) when `key` is one of the predefined shared keys, then the raw key / fallback. So a default widget that calls `t("submit")` picks up the shared translation with no manual key entry, and an author who sets `widget.<id>.submit` in the Translations admin still overrides **that instance only**. An author-invented bare key is never silently shared.
59
+ - **The host auto-registers the shared keys** for a tenant at workspace-content seed time and whenever a marketplace or AI-generated widget is added — idempotent and non-destructive (an admin edit is never overwritten). Authors manage / translate them in the Studio Translations screen like any other key.
60
+ - **`CONTRACT.version` → `1.28.0`** (additive: one new contract field + three helper exports + the `useI18n` shared-key step). No existing export changed signature.
61
+
62
+ ### What's new in 0.37.0
63
+
64
+ **`react/jsx-runtime` + `react/jsx-dev-runtime` are now vetted imports.** A widget bundle compiled with React's *automatic* JSX runtime (Vite/esbuild's default) emits `import { jsx, jsxs, Fragment } from "react/jsx-runtime"` — code the author never writes by hand. The runtime already treated these as host-provided (the web loader shims both, the AI-agent sandbox stubs them, and the Developer guide documents them as externalized), but the linter's vetted list did not list them, so such a bundle failed publish static analysis with `import-not-vetted` on `react/jsx-runtime`. Both are now on `CONTRACT.vettedImports` as `core` subpaths of the already-vetted `react`. **`CONTRACT.version` → `1.27.0`** (additive: two vetted core subpaths). No existing entry changed shape.
51
65
 
52
66
  ### What's new in 0.36.0
53
67
 
package/dist/contract.cjs CHANGED
@@ -1057,6 +1057,20 @@ const VETTED_IMPORTS = [
1057
1057
  category: "core",
1058
1058
  description: "React. Hooks, JSX, lifecycle. Unchanged.",
1059
1059
  },
1060
+ {
1061
+ specifier: "react/jsx-runtime",
1062
+ platforms: ["web", "native"],
1063
+ category: "core",
1064
+ description:
1065
+ "React's automatic JSX runtime (jsx/jsxs/Fragment). Not hand-written — the JSX transform injects this import when a bundle is compiled with the automatic runtime. Host-provided on both platforms (the web loader shims it; Metro resolves the real subpath of react), so it is externalized exactly like react.",
1066
+ },
1067
+ {
1068
+ specifier: "react/jsx-dev-runtime",
1069
+ platforms: ["web", "native"],
1070
+ category: "core",
1071
+ description:
1072
+ "React's automatic JSX dev runtime (jsxDEV/Fragment). The development-mode counterpart to react/jsx-runtime, injected by the JSX transform in dev builds. Host-provided on both platforms, externalized like react.",
1073
+ },
1060
1074
  {
1061
1075
  specifier: "@colixsystems/widget-sdk",
1062
1076
  platforms: ["web", "native"],
@@ -1271,6 +1285,65 @@ function widgetTranslationKey(id, key) {
1271
1285
  return `widget.${id}.${key}`;
1272
1286
  }
1273
1287
 
1288
+ // REQ-L10N-SHARED — predefined SHARED translation keys for the standard
1289
+ // strings the default widgets repeat ("Submit", "Cancel", "Loading…", …).
1290
+ // They live in ONE tenant-wide namespace (`shared.<key>`) rather than the
1291
+ // per-widget namespace, so identical strings translate ONCE and every widget
1292
+ // that calls `t("<sharedKey>")` resolves the same dictionary value. An author
1293
+ // still overrides any instance by setting `widget.<id>.<key>` in the
1294
+ // Translations admin — `useI18n` resolves the per-widget key FIRST, then the
1295
+ // shared key, then the raw key, so the override is per-instance.
1296
+ //
1297
+ // This is the analogue of `widget.<id>.` for cross-widget reuse: one prefix,
1298
+ // one definition (the hook reads + the backend seeder writes both call these),
1299
+ // so the wire key and the lookup key can never drift.
1300
+ const SHARED_TRANSLATION_PREFIX = "shared.";
1301
+ function sharedTranslationPrefix() {
1302
+ return SHARED_TRANSLATION_PREFIX;
1303
+ }
1304
+ function sharedTranslationKey(key) {
1305
+ return `${SHARED_TRANSLATION_PREFIX}${key}`;
1306
+ }
1307
+
1308
+ // The predefined set. Map of bare key -> { en } (English default only; the
1309
+ // tenant adds target-language values once in the Translations admin and every
1310
+ // widget inherits them). Adding/removing a key is a CONTRACT change (it grows
1311
+ // the dictionary every tenant gets), so the list lives with the contract and
1312
+ // the seeder reads it from here — never a second copy.
1313
+ const SHARED_TRANSLATION_KEYS = Object.freeze({
1314
+ submit: { en: "Submit" },
1315
+ cancel: { en: "Cancel" },
1316
+ save: { en: "Save" },
1317
+ delete: { en: "Delete" },
1318
+ edit: { en: "Edit" },
1319
+ close: { en: "Close" },
1320
+ confirm: { en: "Confirm" },
1321
+ back: { en: "Back" },
1322
+ next: { en: "Next" },
1323
+ previous: { en: "Previous" },
1324
+ yes: { en: "Yes" },
1325
+ no: { en: "No" },
1326
+ ok: { en: "OK" },
1327
+ loading: { en: "Loading…" },
1328
+ search: { en: "Search" },
1329
+ no_results: { en: "No results" },
1330
+ required: { en: "Required" },
1331
+ retry: { en: "Retry" },
1332
+ add: { en: "Add" },
1333
+ remove: { en: "Remove" },
1334
+ });
1335
+
1336
+ // True when `key` is one of the predefined shared keys — the discriminator
1337
+ // `useI18n` uses to decide whether to try the `shared.<key>` namespace. A key
1338
+ // the author invents is NOT shared (it stays per-widget) so authors can't
1339
+ // accidentally collide with another widget by reusing a bare name.
1340
+ function isSharedTranslationKey(key) {
1341
+ return (
1342
+ typeof key === "string" &&
1343
+ Object.prototype.hasOwnProperty.call(SHARED_TRANSLATION_KEYS, key)
1344
+ );
1345
+ }
1346
+
1274
1347
  const CONTRACT = deepFreeze({
1275
1348
  // REQ-WSDK-PLATFORM bump:
1276
1349
  // - `vettedImports` is a new field (rich allowlist with platforms +
@@ -1459,7 +1532,36 @@ const CONTRACT = deepFreeze({
1459
1532
  // `users.write:*`. New linter rule `scope-required-for-user-delete`
1460
1533
  // flags a widget that calls `.remove()` without declaring the scope.
1461
1534
  // No hook signature changed; `useUsers().remove` is unchanged.
1462
- version: "1.26.0",
1535
+ //
1536
+ // 1.27.0: additive — the vetted import allowlist gains `react/jsx-runtime`
1537
+ // and `react/jsx-dev-runtime`. These are subpaths of the already-vetted
1538
+ // `react`, injected automatically by the JSX transform when a bundle is
1539
+ // compiled with the automatic JSX runtime (Vite/esbuild's default). The
1540
+ // runtime already treats them as host-provided (the web loader shims both
1541
+ // in widgetLoader.js, the AI-agent sandbox stubs them, and the Developer
1542
+ // guide documents them as externalized), but the linter's vetted list did
1543
+ // not list them — so a first-party/marketplace bundle built with the
1544
+ // automatic runtime failed publish static analysis with `import-not-vetted`
1545
+ // on `react/jsx-runtime`. Adding them converges the linter onto the same
1546
+ // contract every other surface already honoured (CLAUDE.md §3). No
1547
+ // existing entry changed shape — minor bump on the pre-1.0 channel.
1548
+ //
1549
+ // 1.28.0: additive (REQ-L10N-SHARED) — predefined SHARED translation keys.
1550
+ // A new tenant-wide namespace `shared.<key>` carries the standard strings
1551
+ // the default widgets repeat ("Submit", "Cancel", "Loading…", …) so an
1552
+ // identical string is translated ONCE and every widget that calls
1553
+ // `t("<sharedKey>")` inherits it. New contract field
1554
+ // `sharedTranslationKeys` (the predefined map, the single source the
1555
+ // backend seeder reads) + new exported helpers `sharedTranslationPrefix()`
1556
+ // / `sharedTranslationKey(key)` / `isSharedTranslationKey(key)`.
1557
+ // `useI18n().t(key)` now resolves the per-widget key first, THEN the
1558
+ // shared key (when `key` is one of the predefined shared keys), then the
1559
+ // raw key — so a default widget picks up the shared translation with no
1560
+ // manual key entry, and a `widget.<id>.<key>` override still wins per
1561
+ // instance. No existing hook, primitive, manifest field, or token changed
1562
+ // shape — minor bump on the pre-1.0 channel.
1563
+ version: "1.28.0",
1564
+ sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1463
1565
  hooks: HOOKS,
1464
1566
  primitives: PRIMITIVES,
1465
1567
  manifestSchema: MANIFEST_SCHEMA,
@@ -1497,4 +1599,8 @@ module.exports = {
1497
1599
  requiredContextKeys,
1498
1600
  widgetTranslationPrefix,
1499
1601
  widgetTranslationKey,
1602
+ sharedTranslationPrefix,
1603
+ sharedTranslationKey,
1604
+ isSharedTranslationKey,
1605
+ SHARED_TRANSLATION_KEYS,
1500
1606
  };
package/dist/contract.js CHANGED
@@ -1057,6 +1057,20 @@ const VETTED_IMPORTS = [
1057
1057
  category: "core",
1058
1058
  description: "React. Hooks, JSX, lifecycle. Unchanged.",
1059
1059
  },
1060
+ {
1061
+ specifier: "react/jsx-runtime",
1062
+ platforms: ["web", "native"],
1063
+ category: "core",
1064
+ description:
1065
+ "React's automatic JSX runtime (jsx/jsxs/Fragment). Not hand-written — the JSX transform injects this import when a bundle is compiled with the automatic runtime. Host-provided on both platforms (the web loader shims it; Metro resolves the real subpath of react), so it is externalized exactly like react.",
1066
+ },
1067
+ {
1068
+ specifier: "react/jsx-dev-runtime",
1069
+ platforms: ["web", "native"],
1070
+ category: "core",
1071
+ description:
1072
+ "React's automatic JSX dev runtime (jsxDEV/Fragment). The development-mode counterpart to react/jsx-runtime, injected by the JSX transform in dev builds. Host-provided on both platforms, externalized like react.",
1073
+ },
1060
1074
  {
1061
1075
  specifier: "@colixsystems/widget-sdk",
1062
1076
  platforms: ["web", "native"],
@@ -1271,6 +1285,65 @@ function widgetTranslationKey(id, key) {
1271
1285
  return `widget.${id}.${key}`;
1272
1286
  }
1273
1287
 
1288
+ // REQ-L10N-SHARED — predefined SHARED translation keys for the standard
1289
+ // strings the default widgets repeat ("Submit", "Cancel", "Loading…", …).
1290
+ // They live in ONE tenant-wide namespace (`shared.<key>`) rather than the
1291
+ // per-widget namespace, so identical strings translate ONCE and every widget
1292
+ // that calls `t("<sharedKey>")` resolves the same dictionary value. An author
1293
+ // still overrides any instance by setting `widget.<id>.<key>` in the
1294
+ // Translations admin — `useI18n` resolves the per-widget key FIRST, then the
1295
+ // shared key, then the raw key, so the override is per-instance.
1296
+ //
1297
+ // This is the analogue of `widget.<id>.` for cross-widget reuse: one prefix,
1298
+ // one definition (the hook reads + the backend seeder writes both call these),
1299
+ // so the wire key and the lookup key can never drift.
1300
+ const SHARED_TRANSLATION_PREFIX = "shared.";
1301
+ function sharedTranslationPrefix() {
1302
+ return SHARED_TRANSLATION_PREFIX;
1303
+ }
1304
+ function sharedTranslationKey(key) {
1305
+ return `${SHARED_TRANSLATION_PREFIX}${key}`;
1306
+ }
1307
+
1308
+ // The predefined set. Map of bare key -> { en } (English default only; the
1309
+ // tenant adds target-language values once in the Translations admin and every
1310
+ // widget inherits them). Adding/removing a key is a CONTRACT change (it grows
1311
+ // the dictionary every tenant gets), so the list lives with the contract and
1312
+ // the seeder reads it from here — never a second copy.
1313
+ const SHARED_TRANSLATION_KEYS = Object.freeze({
1314
+ submit: { en: "Submit" },
1315
+ cancel: { en: "Cancel" },
1316
+ save: { en: "Save" },
1317
+ delete: { en: "Delete" },
1318
+ edit: { en: "Edit" },
1319
+ close: { en: "Close" },
1320
+ confirm: { en: "Confirm" },
1321
+ back: { en: "Back" },
1322
+ next: { en: "Next" },
1323
+ previous: { en: "Previous" },
1324
+ yes: { en: "Yes" },
1325
+ no: { en: "No" },
1326
+ ok: { en: "OK" },
1327
+ loading: { en: "Loading…" },
1328
+ search: { en: "Search" },
1329
+ no_results: { en: "No results" },
1330
+ required: { en: "Required" },
1331
+ retry: { en: "Retry" },
1332
+ add: { en: "Add" },
1333
+ remove: { en: "Remove" },
1334
+ });
1335
+
1336
+ // True when `key` is one of the predefined shared keys — the discriminator
1337
+ // `useI18n` uses to decide whether to try the `shared.<key>` namespace. A key
1338
+ // the author invents is NOT shared (it stays per-widget) so authors can't
1339
+ // accidentally collide with another widget by reusing a bare name.
1340
+ function isSharedTranslationKey(key) {
1341
+ return (
1342
+ typeof key === "string" &&
1343
+ Object.prototype.hasOwnProperty.call(SHARED_TRANSLATION_KEYS, key)
1344
+ );
1345
+ }
1346
+
1274
1347
  const CONTRACT = deepFreeze({
1275
1348
  // REQ-WSDK-PLATFORM bump:
1276
1349
  // - `vettedImports` is a new field (rich allowlist with platforms +
@@ -1459,7 +1532,36 @@ const CONTRACT = deepFreeze({
1459
1532
  // `users.write:*`. New linter rule `scope-required-for-user-delete`
1460
1533
  // flags a widget that calls `.remove()` without declaring the scope.
1461
1534
  // No hook signature changed; `useUsers().remove` is unchanged.
1462
- version: "1.26.0",
1535
+ //
1536
+ // 1.27.0: additive — the vetted import allowlist gains `react/jsx-runtime`
1537
+ // and `react/jsx-dev-runtime`. These are subpaths of the already-vetted
1538
+ // `react`, injected automatically by the JSX transform when a bundle is
1539
+ // compiled with the automatic JSX runtime (Vite/esbuild's default). The
1540
+ // runtime already treats them as host-provided (the web loader shims both
1541
+ // in widgetLoader.js, the AI-agent sandbox stubs them, and the Developer
1542
+ // guide documents them as externalized), but the linter's vetted list did
1543
+ // not list them — so a first-party/marketplace bundle built with the
1544
+ // automatic runtime failed publish static analysis with `import-not-vetted`
1545
+ // on `react/jsx-runtime`. Adding them converges the linter onto the same
1546
+ // contract every other surface already honoured (CLAUDE.md §3). No
1547
+ // existing entry changed shape — minor bump on the pre-1.0 channel.
1548
+ //
1549
+ // 1.28.0: additive (REQ-L10N-SHARED) — predefined SHARED translation keys.
1550
+ // A new tenant-wide namespace `shared.<key>` carries the standard strings
1551
+ // the default widgets repeat ("Submit", "Cancel", "Loading…", …) so an
1552
+ // identical string is translated ONCE and every widget that calls
1553
+ // `t("<sharedKey>")` inherits it. New contract field
1554
+ // `sharedTranslationKeys` (the predefined map, the single source the
1555
+ // backend seeder reads) + new exported helpers `sharedTranslationPrefix()`
1556
+ // / `sharedTranslationKey(key)` / `isSharedTranslationKey(key)`.
1557
+ // `useI18n().t(key)` now resolves the per-widget key first, THEN the
1558
+ // shared key (when `key` is one of the predefined shared keys), then the
1559
+ // raw key — so a default widget picks up the shared translation with no
1560
+ // manual key entry, and a `widget.<id>.<key>` override still wins per
1561
+ // instance. No existing hook, primitive, manifest field, or token changed
1562
+ // shape — minor bump on the pre-1.0 channel.
1563
+ version: "1.28.0",
1564
+ sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1463
1565
  hooks: HOOKS,
1464
1566
  primitives: PRIMITIVES,
1465
1567
  manifestSchema: MANIFEST_SCHEMA,
@@ -1497,4 +1599,8 @@ export {
1497
1599
  requiredContextKeys,
1498
1600
  widgetTranslationPrefix,
1499
1601
  widgetTranslationKey,
1602
+ sharedTranslationPrefix,
1603
+ sharedTranslationKey,
1604
+ isSharedTranslationKey,
1605
+ SHARED_TRANSLATION_KEYS,
1500
1606
  };
package/dist/hooks.js CHANGED
@@ -26,10 +26,18 @@ import React, {
26
26
  useRef,
27
27
  useState,
28
28
  } from "react";
29
- // REQ-L10N-WIDGET: the per-widget translation key format lives in ONE place
30
- // (contract.js). useI18n (lookup) and the backend seeder (write) both call it
31
- // so the namespaced key can never drift between the two sides.
32
- import { widgetTranslationKey } from "./contract.js";
29
+ // REQ-L10N-WIDGET / REQ-L10N-SHARED: the translation key formats live in ONE
30
+ // place (contract.js). useI18n (lookup) and the backend seeder (write) both
31
+ // call them so the namespaced keys can never drift between the two sides.
32
+ // `widgetTranslationKey` builds the per-widget key (`widget.<id>.<key>`);
33
+ // `sharedTranslationKey` builds the tenant-wide predefined key
34
+ // (`shared.<key>`); `isSharedTranslationKey` tells the hook whether a bare key
35
+ // is one of the predefined shared keys and so should try the shared namespace.
36
+ import {
37
+ widgetTranslationKey,
38
+ sharedTranslationKey,
39
+ isSharedTranslationKey,
40
+ } from "./contract.js";
33
41
 
34
42
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
35
43
  const HostWidgetContext = createContext(null);
@@ -240,7 +248,27 @@ export function useI18n() {
240
248
  return scoped;
241
249
  }
242
250
  }
243
- // 2) Fall back to the raw key (shared app keys + widgets with no
251
+ // 2) REQ-L10N-SHARED: for a predefined SHARED key, try the tenant-wide
252
+ // `shared.<key>` namespace next. This is what lets identical
253
+ // default-widget strings ("Submit", "Cancel", …) translate ONCE: a
254
+ // widget that has no per-instance `widget.<id>.<key>` override
255
+ // inherits the shared value. The per-widget step above runs FIRST,
256
+ // so an author override of a single instance still wins for that
257
+ // instance. Only the predefined keys are tried here — an
258
+ // author-invented bare key is never silently shared.
259
+ if (isSharedTranslationKey(key)) {
260
+ const shared = sharedTranslationKey(key);
261
+ const scoped = hostT(shared);
262
+ if (
263
+ typeof scoped === "string" &&
264
+ scoped.length > 0 &&
265
+ scoped !== shared &&
266
+ !scoped.startsWith("{{t:")
267
+ ) {
268
+ return scoped;
269
+ }
270
+ }
271
+ // 3) Fall back to the raw key (shared app keys + widgets with no
244
272
  // manifest translations — unchanged from pre-1.10 behaviour).
245
273
  const out = hostT(key, fallback);
246
274
  if (typeof out === "string" && out.length > 0 && out !== key) {
package/dist/linter.cjs CHANGED
@@ -54,6 +54,149 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
54
54
  _ruleForIdentifier(b.identifier, b.reason),
55
55
  );
56
56
 
57
+ // Replace the *content* of comments and string / template literals with
58
+ // spaces so the banned-identifier scan only ever sees executable code. A
59
+ // banned host-escape identifier (`window`, `document`, `eval`, `process`, …)
60
+ // is only dangerous as a real identifier reference — never as prose in a
61
+ // `//` comment or as character data inside a string — so matching the bare
62
+ // word there is a false positive that blocks an otherwise-clean widget (a
63
+ // comment that reads "the hour window the grid renders" must not trip
64
+ // `no-window`).
65
+ //
66
+ // Newlines are preserved verbatim so reported line numbers still line up
67
+ // with the original source. Template-literal `${ … }` expression holes are
68
+ // left intact: real code lives there and must still be scanned (`${window}`
69
+ // is a genuine escape). Backslash escapes inside strings/templates are
70
+ // consumed so an escaped quote (`"\""`) doesn't end the literal early.
71
+ function _stripNonCode(source) {
72
+ let out = "";
73
+ const n = source.length;
74
+ let mode = "code"; // code | line | block | sq | dq | tmpl
75
+ // Brace depth, plus a stack of the depths at which an enclosing template
76
+ // literal resumes — lets a `${ … }` hole (which may itself contain `{}`,
77
+ // strings, or nested templates) be told apart from the literal text.
78
+ let braceDepth = 0;
79
+ const tmplStack = [];
80
+ const keep = (ch) => {
81
+ out += ch;
82
+ };
83
+ const blank = (ch) => {
84
+ out += ch === "\n" || ch === "\r" ? ch : " ";
85
+ };
86
+ let i = 0;
87
+ while (i < n) {
88
+ const ch = source[i];
89
+ const nx = source[i + 1];
90
+ if (mode === "code") {
91
+ if (ch === "/" && nx === "/") {
92
+ mode = "line";
93
+ blank(ch);
94
+ blank(nx);
95
+ i += 2;
96
+ } else if (ch === "/" && nx === "*") {
97
+ mode = "block";
98
+ blank(ch);
99
+ blank(nx);
100
+ i += 2;
101
+ } else if (ch === "'") {
102
+ mode = "sq";
103
+ blank(ch);
104
+ i += 1;
105
+ } else if (ch === '"') {
106
+ mode = "dq";
107
+ blank(ch);
108
+ i += 1;
109
+ } else if (ch === "`") {
110
+ mode = "tmpl";
111
+ blank(ch);
112
+ i += 1;
113
+ } else if (ch === "{") {
114
+ braceDepth += 1;
115
+ keep(ch);
116
+ i += 1;
117
+ } else if (ch === "}") {
118
+ braceDepth -= 1;
119
+ if (
120
+ tmplStack.length > 0 &&
121
+ tmplStack[tmplStack.length - 1] === braceDepth
122
+ ) {
123
+ tmplStack.pop();
124
+ mode = "tmpl";
125
+ blank(ch);
126
+ } else {
127
+ keep(ch);
128
+ }
129
+ i += 1;
130
+ } else {
131
+ keep(ch);
132
+ i += 1;
133
+ }
134
+ } else if (mode === "line") {
135
+ if (ch === "\n") {
136
+ mode = "code";
137
+ keep(ch);
138
+ } else {
139
+ blank(ch);
140
+ }
141
+ i += 1;
142
+ } else if (mode === "block") {
143
+ if (ch === "*" && nx === "/") {
144
+ mode = "code";
145
+ blank(ch);
146
+ blank(nx);
147
+ i += 2;
148
+ } else {
149
+ blank(ch);
150
+ i += 1;
151
+ }
152
+ } else if (mode === "sq" || mode === "dq") {
153
+ const quote = mode === "sq" ? "'" : '"';
154
+ if (ch === "\\") {
155
+ blank(ch);
156
+ if (i + 1 < n) blank(nx);
157
+ i += 2;
158
+ } else if (ch === quote) {
159
+ mode = "code";
160
+ blank(ch);
161
+ i += 1;
162
+ } else if (ch === "\n") {
163
+ // A bare newline terminates an unterminated string in JS; bail back
164
+ // to code so malformed input can't blank the rest of the file.
165
+ mode = "code";
166
+ keep(ch);
167
+ i += 1;
168
+ } else {
169
+ blank(ch);
170
+ i += 1;
171
+ }
172
+ } else {
173
+ // mode === "tmpl"
174
+ if (ch === "\\") {
175
+ blank(ch);
176
+ if (i + 1 < n) blank(nx);
177
+ i += 2;
178
+ } else if (ch === "`") {
179
+ mode = "code";
180
+ blank(ch);
181
+ i += 1;
182
+ } else if (ch === "$" && nx === "{") {
183
+ // Enter an expression hole. Remember the brace depth the template
184
+ // resumes at, then count the `{` so its matching `}` is recognised.
185
+ tmplStack.push(braceDepth);
186
+ braceDepth += 1;
187
+ mode = "code";
188
+ keep(ch);
189
+ keep(nx);
190
+ i += 2;
191
+ } else {
192
+ blank(ch);
193
+ i += 1;
194
+ }
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
57
200
  // REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
58
201
  // import list now (`CONTRACT.vettedImports`). See linter.js for the
59
202
  // source-of-truth comment.
@@ -387,16 +530,22 @@ function lintSource(source, options) {
387
530
  }
388
531
  const findings = [];
389
532
  const lines = source.split(/\r?\n/);
533
+ // Scan code with comments + string/template text blanked out so a banned
534
+ // identifier only fires on an actual code reference, not on the same word
535
+ // appearing in prose or string data. `codeLines` lines up 1:1 with `lines`
536
+ // (masking preserves newlines), so the reported snippet still comes from
537
+ // the original source.
538
+ const codeLines = _stripNonCode(source).split(/\r?\n/);
390
539
  for (let i = 0; i < lines.length; i++) {
391
- const line = lines[i];
540
+ const codeLine = codeLines[i];
392
541
  for (const rule of RULES) {
393
- if (rule.pattern.test(line)) {
542
+ if (rule.pattern.test(codeLine)) {
394
543
  findings.push({
395
544
  rule: rule.id,
396
545
  severity: "error",
397
546
  label: rule.label,
398
547
  line: i + 1,
399
- snippet: line.trim().slice(0, 200),
548
+ snippet: lines[i].trim().slice(0, 200),
400
549
  });
401
550
  }
402
551
  }
package/dist/linter.js CHANGED
@@ -55,6 +55,149 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
55
55
  _ruleForIdentifier(b.identifier, b.reason),
56
56
  );
57
57
 
58
+ // Replace the *content* of comments and string / template literals with
59
+ // spaces so the banned-identifier scan only ever sees executable code. A
60
+ // banned host-escape identifier (`window`, `document`, `eval`, `process`, …)
61
+ // is only dangerous as a real identifier reference — never as prose in a
62
+ // `//` comment or as character data inside a string — so matching the bare
63
+ // word there is a false positive that blocks an otherwise-clean widget (a
64
+ // comment that reads "the hour window the grid renders" must not trip
65
+ // `no-window`).
66
+ //
67
+ // Newlines are preserved verbatim so reported line numbers still line up
68
+ // with the original source. Template-literal `${ … }` expression holes are
69
+ // left intact: real code lives there and must still be scanned (`${window}`
70
+ // is a genuine escape). Backslash escapes inside strings/templates are
71
+ // consumed so an escaped quote (`"\""`) doesn't end the literal early.
72
+ function _stripNonCode(source) {
73
+ let out = "";
74
+ const n = source.length;
75
+ let mode = "code"; // code | line | block | sq | dq | tmpl
76
+ // Brace depth, plus a stack of the depths at which an enclosing template
77
+ // literal resumes — lets a `${ … }` hole (which may itself contain `{}`,
78
+ // strings, or nested templates) be told apart from the literal text.
79
+ let braceDepth = 0;
80
+ const tmplStack = [];
81
+ const keep = (ch) => {
82
+ out += ch;
83
+ };
84
+ const blank = (ch) => {
85
+ out += ch === "\n" || ch === "\r" ? ch : " ";
86
+ };
87
+ let i = 0;
88
+ while (i < n) {
89
+ const ch = source[i];
90
+ const nx = source[i + 1];
91
+ if (mode === "code") {
92
+ if (ch === "/" && nx === "/") {
93
+ mode = "line";
94
+ blank(ch);
95
+ blank(nx);
96
+ i += 2;
97
+ } else if (ch === "/" && nx === "*") {
98
+ mode = "block";
99
+ blank(ch);
100
+ blank(nx);
101
+ i += 2;
102
+ } else if (ch === "'") {
103
+ mode = "sq";
104
+ blank(ch);
105
+ i += 1;
106
+ } else if (ch === '"') {
107
+ mode = "dq";
108
+ blank(ch);
109
+ i += 1;
110
+ } else if (ch === "`") {
111
+ mode = "tmpl";
112
+ blank(ch);
113
+ i += 1;
114
+ } else if (ch === "{") {
115
+ braceDepth += 1;
116
+ keep(ch);
117
+ i += 1;
118
+ } else if (ch === "}") {
119
+ braceDepth -= 1;
120
+ if (
121
+ tmplStack.length > 0 &&
122
+ tmplStack[tmplStack.length - 1] === braceDepth
123
+ ) {
124
+ tmplStack.pop();
125
+ mode = "tmpl";
126
+ blank(ch);
127
+ } else {
128
+ keep(ch);
129
+ }
130
+ i += 1;
131
+ } else {
132
+ keep(ch);
133
+ i += 1;
134
+ }
135
+ } else if (mode === "line") {
136
+ if (ch === "\n") {
137
+ mode = "code";
138
+ keep(ch);
139
+ } else {
140
+ blank(ch);
141
+ }
142
+ i += 1;
143
+ } else if (mode === "block") {
144
+ if (ch === "*" && nx === "/") {
145
+ mode = "code";
146
+ blank(ch);
147
+ blank(nx);
148
+ i += 2;
149
+ } else {
150
+ blank(ch);
151
+ i += 1;
152
+ }
153
+ } else if (mode === "sq" || mode === "dq") {
154
+ const quote = mode === "sq" ? "'" : '"';
155
+ if (ch === "\\") {
156
+ blank(ch);
157
+ if (i + 1 < n) blank(nx);
158
+ i += 2;
159
+ } else if (ch === quote) {
160
+ mode = "code";
161
+ blank(ch);
162
+ i += 1;
163
+ } else if (ch === "\n") {
164
+ // A bare newline terminates an unterminated string in JS; bail back
165
+ // to code so malformed input can't blank the rest of the file.
166
+ mode = "code";
167
+ keep(ch);
168
+ i += 1;
169
+ } else {
170
+ blank(ch);
171
+ i += 1;
172
+ }
173
+ } else {
174
+ // mode === "tmpl"
175
+ if (ch === "\\") {
176
+ blank(ch);
177
+ if (i + 1 < n) blank(nx);
178
+ i += 2;
179
+ } else if (ch === "`") {
180
+ mode = "code";
181
+ blank(ch);
182
+ i += 1;
183
+ } else if (ch === "$" && nx === "{") {
184
+ // Enter an expression hole. Remember the brace depth the template
185
+ // resumes at, then count the `{` so its matching `}` is recognised.
186
+ tmplStack.push(braceDepth);
187
+ braceDepth += 1;
188
+ mode = "code";
189
+ keep(ch);
190
+ keep(nx);
191
+ i += 2;
192
+ } else {
193
+ blank(ch);
194
+ i += 1;
195
+ }
196
+ }
197
+ }
198
+ return out;
199
+ }
200
+
58
201
  // Extra rules that don't map 1:1 to a banned identifier in the contract:
59
202
  // host-internal imports that widgets must never touch.
60
203
  //
@@ -124,9 +267,7 @@ function _classifySpecifier(spec) {
124
267
 
125
268
  function _importRules(source, manifest) {
126
269
  const findings = [];
127
- const allowed = new Map(
128
- CONTRACT.vettedImports.map((v) => [v.specifier, v]),
129
- );
270
+ const allowed = new Map(CONTRACT.vettedImports.map((v) => [v.specifier, v]));
130
271
  // Track declared `supportedPlatforms` so a widget that claims "web only"
131
272
  // doesn't import a native-only package (and vice versa) without the
132
273
  // marketplace listing being honest about which platforms ship.
@@ -258,7 +399,12 @@ const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate"];
258
399
  // useUsers().remove() must declare `users.delete:*` so the static contract
259
400
  // matches the backend gate on DELETE /api/v1/app/users/:userId.
260
401
  const USER_DELETE_METHODS = ["remove"];
261
- const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
402
+ const GROUP_MUTATION_METHODS = [
403
+ "create",
404
+ "remove",
405
+ "addMember",
406
+ "removeMember",
407
+ ];
262
408
  const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
263
409
 
264
410
  function _scopeRules(source, manifest) {
@@ -277,8 +423,7 @@ function _scopeRules(source, manifest) {
277
423
  if (!reads) {
278
424
  findings.push({
279
425
  rule: "scope-required-for-useUsers",
280
- label:
281
- "useUsers() requires `users.read:*` in manifest.requestedScopes",
426
+ label: "useUsers() requires `users.read:*` in manifest.requestedScopes",
282
427
  line: 0,
283
428
  snippet: "",
284
429
  });
@@ -308,10 +453,7 @@ function _scopeRules(source, manifest) {
308
453
  for (const m of USER_MUTATION_METHODS) {
309
454
  const re = new RegExp(`\\.${m}\\s*\\(`);
310
455
  if (re.test(line)) {
311
- if (
312
- !declared.has("users.write:*") &&
313
- !declared.has("users.write")
314
- ) {
456
+ if (!declared.has("users.write:*") && !declared.has("users.write")) {
315
457
  findings.push({
316
458
  rule: "scope-required-for-user-mutation",
317
459
  label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
@@ -400,7 +542,13 @@ function _manifestActionRules(manifest) {
400
542
  const validTriggers = new Set(CONTRACT.actionTriggerTypes);
401
543
  const maxBytes = CONTRACT.actionScriptMaxBytes;
402
544
  const push = (label) =>
403
- findings.push({ rule: "manifest-action", severity: "error", label, line: 0, snippet: "" });
545
+ findings.push({
546
+ rule: "manifest-action",
547
+ severity: "error",
548
+ label,
549
+ line: 0,
550
+ snippet: "",
551
+ });
404
552
  if (!Array.isArray(manifest.actions)) {
405
553
  push("manifest.actions must be an array (omit it or use [] for none)");
406
554
  return findings;
@@ -422,9 +570,16 @@ function _manifestActionRules(manifest) {
422
570
  push("manifest.actions[].name must be a non-empty string");
423
571
  }
424
572
  if (!validTriggers.has(a.triggerType)) {
425
- push(`manifest.actions[].triggerType must be one of ${[...validTriggers].join(", ")}`);
426
- } else if (a.triggerType === "schedule" && (typeof a.scheduleCron !== "string" || !a.scheduleCron)) {
427
- push("manifest.actions[].scheduleCron is required when triggerType is 'schedule'");
573
+ push(
574
+ `manifest.actions[].triggerType must be one of ${[...validTriggers].join(", ")}`,
575
+ );
576
+ } else if (
577
+ a.triggerType === "schedule" &&
578
+ (typeof a.scheduleCron !== "string" || !a.scheduleCron)
579
+ ) {
580
+ push(
581
+ "manifest.actions[].scheduleCron is required when triggerType is 'schedule'",
582
+ );
428
583
  }
429
584
  if (typeof a.scriptSource !== "string" || a.scriptSource.length === 0) {
430
585
  push("manifest.actions[].scriptSource must be a non-empty string");
@@ -438,7 +593,9 @@ function _manifestActionRules(manifest) {
438
593
  }
439
594
  }
440
595
  if (a.triggerTableId !== undefined || a.apiKeyId !== undefined) {
441
- push("manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install");
596
+ push(
597
+ "manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install",
598
+ );
442
599
  }
443
600
  }
444
601
  return findings;
@@ -461,16 +618,22 @@ export function lintSource(source, options) {
461
618
  }
462
619
  const findings = [];
463
620
  const lines = source.split(/\r?\n/);
621
+ // Scan code with comments + string/template text blanked out so a banned
622
+ // identifier only fires on an actual code reference, not on the same word
623
+ // appearing in prose or string data. `codeLines` lines up 1:1 with `lines`
624
+ // (masking preserves newlines), so the reported snippet still comes from
625
+ // the original source.
626
+ const codeLines = _stripNonCode(source).split(/\r?\n/);
464
627
  for (let i = 0; i < lines.length; i++) {
465
- const line = lines[i];
628
+ const codeLine = codeLines[i];
466
629
  for (const rule of RULES) {
467
- if (rule.pattern.test(line)) {
630
+ if (rule.pattern.test(codeLine)) {
468
631
  findings.push({
469
632
  rule: rule.id,
470
633
  severity: "error",
471
634
  label: rule.label,
472
635
  line: i + 1,
473
- snippet: line.trim().slice(0, 200),
636
+ snippet: lines[i].trim().slice(0, 200),
474
637
  });
475
638
  }
476
639
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.36.0",
3
+ "version": "0.38.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",
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  "scripts": {
37
37
  "build": "node scripts/build.js",
38
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js"
38
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js"
39
39
  },
40
40
  "engines": {
41
41
  "node": ">=18"