@colixsystems/widget-sdk 0.64.0 → 0.66.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 +24 -4
- package/dist/cli.js +14 -4
- package/dist/contract.cjs +52 -18
- package/dist/contract.js +52 -18
- package/dist/hooks.js +6 -3
- package/dist/linter.cjs +111 -17
- package/dist/linter.js +140 -24
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -53,7 +53,26 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
53
53
|
|
|
54
54
|
## Status
|
|
55
55
|
|
|
56
|
-
`v0.
|
|
56
|
+
`v0.66.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**.
|
|
57
|
+
|
|
58
|
+
### What's new in 0.66.0
|
|
59
|
+
|
|
60
|
+
**New linter rule `image-percent-height`, and `appstudio-widget lint` finally prints warnings (sc-3493).**
|
|
61
|
+
|
|
62
|
+
- **`image-percent-height` (severity `warning`, non-blocking).** An `<Image>` / `<ImageBackground>` sized with a literal percentage `height` — `style={{ width: "100%", height: "47%" }}` — is flagged. React Native / Yoga resolves a percentage height against the **parent's** height, so under a content-sized parent it collapses to 0: the `uri` still fetches, but the image is invisible on both the web Player and the native Expo export, with nothing in the console to trace. Author fix: size it with `aspectRatio` (`{ width: "100%", aspectRatio: 1 }`) or a numeric pixel height. It is a **warning**, not an error, precisely because `height: "100%"` *is* correct inside a parent with a definite height (a fixed-height hero) and a text scan cannot tell the two apart — so the rule informs without rejecting a valid widget. Scope is the literal inline form only; a height threaded through a variable or a `StyleSheet` object is beyond an AST-free scan, and the guidance in the `useFilestoreFile` note below remains the primary guard. Comments are not scanned, so documenting the anti-pattern is safe.
|
|
63
|
+
- **The CLI no longer swallows warnings.** `runLint` reported `clean` and dropped every `severity: "warning"` finding whenever there were no errors, which made the existing `no-host-api-url` warning (and this new one) invisible to anyone using `appstudio-widget lint`. It now prints an `N error(s), M warning(s)` header and one line per finding tagged `error` / `warning`. **Exit codes are unchanged:** `0` when there are no error-severity findings (warnings included), `1` otherwise — so a warning still never blocks a build. `clean` is printed only when there are genuinely zero findings.
|
|
64
|
+
|
|
65
|
+
`CONTRACT` is unchanged (no new field), and no export changed signature.
|
|
66
|
+
|
|
67
|
+
### What's new in 0.65.0
|
|
68
|
+
|
|
69
|
+
**Every file record carries `url`, and hand-built file URLs are linted (sc-3589 follow-up).** Two fixes for the same real-world failure: a `FILE` column's image silently not rendering.
|
|
70
|
+
|
|
71
|
+
- **`url` is now on every file record**, aliasing the absolutized `presigned_url`. The wire field is `presigned_url`, so the intuitive `file.url` read was `undefined` — and because the usual guard is `if (!file.url) return null`, the widget rendered *nothing*, with no error to trace. The alias is added in the filestore client's one shared normalizer, so it applies to `useFilestoreFile(id)` **and every row of `useFilestoreFiles`** — a gallery can render `files.map(f => f.url)` directly. `presigned_url` is unchanged and still present. Requires `@colixsystems/filestore-client` ≥ 0.7.0.
|
|
72
|
+
- **Prefer the top-level `url`:** `const { url } = useFilestoreFile(id)`. The returned `file` is `null` until the fetch resolves (and stays null for an empty `FILE` cell), so `const { file } = …; file.url` throws on the first render. `file.url` is correct only *after* you null-check `file`.
|
|
73
|
+
- **`no-host-api-url` now flags hand-built host paths.** The needles were only `/api/v1`, `/uploads/` and `Authorization: Bearer`, so `` `/api/files/${id}` `` — a route that does not exist — passed clean and shipped. The rule now matches `/api/files/` anywhere (so the origin-prefixed `` `${location.origin}/api/files/${id}` `` is caught too) plus a *quoted* relative `/api/` path for invented prefixes generally. Two deliberate carve-outs: **comments are not scanned**, so documenting the rule in a comment is safe; and an absolute third-party URL that merely contains `/api/` (`https://api.example.com/api/x`) never matches. If a third-party call genuinely needs a *relative* `/api/…` path against an axios `baseURL`, add `// appstudio-lint-ignore no-host-api-url` on that line or the line above.
|
|
74
|
+
|
|
75
|
+
**Never build a file URL from an id.** Filestore bytes are only reachable through a server-signed token URL, so a client-composed path can never work — always go through `useFilestoreFile`. `CONTRACT.version` → `1.44.0`. Both changes additive; no export changed signature.
|
|
57
76
|
|
|
58
77
|
### What's new in 0.64.0
|
|
59
78
|
|
|
@@ -234,8 +253,8 @@ Also: `useFileSignatures(fileIds)` is now **self-scoped** (the caller's own sign
|
|
|
234
253
|
### What's new in 0.30.0
|
|
235
254
|
|
|
236
255
|
**Filestore browsing + BankID file signing for widgets (REQ-FS / REQ-SIGN).** Three new hooks read a newly-injected `ctx.filestore` (the `@colixsystems/filestore-client`, now constructed by both the web and native hosts):
|
|
237
|
-
- `useFilestoreFiles({ spaceType, folderId?, q?, type? })` → `{ files, loading, error, refetch }` — browses the end-user's Filestore space. The hook resolves `owner_id` from the host context (tenant for a project space, the app user for a personal space), so the widget only picks the space.
|
|
238
|
-
- `useFilestoreFile(fileId)` → `{ file, url, loading, error, refetch }` — resolves ONE file id to a displayable `url` (its `presigned_url`, absolutized for web + native) via `ctx.filestore.files.get(id)`. A datastore `FILE` column holds — and reads back as — that **bare file-id string**; it is NOT hydrated into an object the way a `RELATION` (`{ id, label }`) or `USER` (`{ id, name }`) column is, so pass the value straight to the hook (no `{ id }` / `{ url }` unwrapping guard). Empty / deleted / not-found ids resolve to `url: null` so a display widget shows a fallback. Requires `files.read:*`.
|
|
256
|
+
- `useFilestoreFiles({ spaceType, folderId?, q?, type? })` → `{ files, loading, error, refetch }` — browses the end-user's Filestore space. The hook resolves `owner_id` from the host context (tenant for a project space, the app user for a personal space), so the widget only picks the space. Every row carries a ready-to-render `url` (its absolutized `presigned_url`), so a gallery renders `files.map(f => f.url)` directly — no per-row `useFilestoreFile` call.
|
|
257
|
+
- `useFilestoreFile(fileId)` → `{ file, url, loading, error, refetch }` — resolves ONE file id to a displayable `url` (its `presigned_url`, absolutized for web + native) via `ctx.filestore.files.get(id)`. **Read the top-level `url`** — the returned `file` is `null` until the fetch resolves (and for an empty id), so `file.url` throws on the first render; it is correct only after a null check. **Never compose a file URL yourself** — the bytes are served only from a server-signed token URL, so a hand-built path like `/api/files/<id>` can never resolve (the linter's `no-host-api-url` rule flags it, including the `${location.origin}/api/files/…` form). A datastore `FILE` column holds — and reads back as — that **bare file-id string**; it is NOT hydrated into an object the way a `RELATION` (`{ id, label }`) or `USER` (`{ id, name }`) column is, so pass the value straight to the hook (no `{ id }` / `{ url }` unwrapping guard). Empty / deleted / not-found ids resolve to `url: null` so a display widget shows a fallback. When you render `url` in an `<Image>` that fills its container, size it with `aspectRatio` (e.g. `{ width: "100%", aspectRatio: 1 }`) or pixels — never a percentage `height`, which React Native collapses to 0 against a content-sized parent, so the image loads but is invisible. Requires `files.read:*`.
|
|
239
258
|
- `useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })` → `{ folders, loading, error, refetch }` — the folder-navigation companion to `useFilestoreFiles`; pass `enabled:false` to suspend fetching.
|
|
240
259
|
- `useFilestoreUpload({ spaceType, folderId? })` → `{ upload, uploading, error, lastUploaded }` — POSTs a multipart upload to `ctx.filestore.files.upload`. Resolves `owner_id` from the host context (like the read hooks) so the widget only picks the space + destination folder. Pair with the `<FilePicker>` primitive for the visible trigger. Requires the `files.write:*` scope.
|
|
241
260
|
- `useFileSignature(fileId)` → `{ status, qr, signerName, verdict, initiate, refresh, cancel, verify, … }` — drives a BankID signing flow for a file (the backend hashes the bytes server-side, binds the digest into the signature, and verifies the proof offline).
|
|
@@ -489,8 +508,9 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
489
508
|
- **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
|
|
490
509
|
- **Set the theme font on every `Text`.** React Native `Text` does not inherit `fontFamily` from a parent, so a text element that omits it falls back to the system font and ignores the workspace's configured font. Put `theme.typography.fontFamily` on every text style (a shared `StyleSheet` built from `theme` keeps it in one place) and size text with `theme.typography.sizes`.
|
|
491
510
|
- **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.md` + a `colors.border` hairline or a subtle shadow. Use the status roles (`danger / success / warning / info`) for state.
|
|
511
|
+
- **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the native-safe way to go multi-column (widgets have no breakpoint hook, so never hard-code fixed columns). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only).
|
|
492
512
|
- **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
|
|
493
|
-
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme.
|
|
513
|
+
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
|
|
494
514
|
- **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead.
|
|
495
515
|
- **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
|
|
496
516
|
|
package/dist/cli.js
CHANGED
|
@@ -60,15 +60,25 @@ function runLint(rest) {
|
|
|
60
60
|
exit(1);
|
|
61
61
|
}
|
|
62
62
|
const { ok, findings } = lintSource(source);
|
|
63
|
-
if (
|
|
63
|
+
if (findings.length === 0) {
|
|
64
64
|
stdout.write(`${filePath}: clean\n`);
|
|
65
65
|
exit(0);
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
// sc-3493 — a warning-severity finding used to be swallowed: `ok` stays true
|
|
68
|
+
// for warnings, so the CLI printed "clean" and dropped them. A warning nobody
|
|
69
|
+
// sees is pointless. Report every finding; only errors change the exit code.
|
|
70
|
+
const errors = findings.filter((f) => f.severity !== "warning").length;
|
|
71
|
+
const stream = ok ? stdout : stderr;
|
|
72
|
+
stream.write(
|
|
73
|
+
`${filePath}: ${errors} error(s), ${findings.length - errors} warning(s)\n`,
|
|
74
|
+
);
|
|
68
75
|
for (const f of findings) {
|
|
69
|
-
|
|
76
|
+
const severity = f.severity === "warning" ? "warning" : "error";
|
|
77
|
+
stream.write(
|
|
78
|
+
` ${severity} [${f.rule}] line ${f.line}: ${f.label}\n ${f.snippet}\n`,
|
|
79
|
+
);
|
|
70
80
|
}
|
|
71
|
-
exit(1);
|
|
81
|
+
exit(ok ? 0 : 1);
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
async function runDev(rest) {
|
package/dist/contract.cjs
CHANGED
|
@@ -211,10 +211,14 @@ const HOOKS = [
|
|
|
211
211
|
name: "useFilestoreFiles",
|
|
212
212
|
signature: "useFilestoreFiles({ spaceType, folderId?, q?, type? })",
|
|
213
213
|
description:
|
|
214
|
-
"Browse the end-user's Filestore files in a project or
|
|
215
|
-
"The hook resolves owner_id from the host context (tenant for
|
|
216
|
-
"app user for personal) — the widget only chooses the
|
|
217
|
-
"
|
|
214
|
+
"Browse the end-user's Filestore files in a project, personal, or public " +
|
|
215
|
+
"space. The hook resolves owner_id from the host context (tenant for " +
|
|
216
|
+
"project/public, app user for personal) — the widget only chooses the " +
|
|
217
|
+
"space. A public space is world-readable: its files render even for " +
|
|
218
|
+
"logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
|
|
219
|
+
"{ data, meta } to the files array. Every row carries a ready-to-render " +
|
|
220
|
+
"`url` (its absolutized presigned_url, aliased by the filestore client) " +
|
|
221
|
+
"so a gallery renders `files.map(f => f.url)` directly.",
|
|
218
222
|
returnShape: {
|
|
219
223
|
files: "FilestoreFile[]",
|
|
220
224
|
loading: "boolean",
|
|
@@ -236,7 +240,12 @@ const HOOKS = [
|
|
|
236
240
|
"absolutized by the client so it loads on web AND native. An empty id " +
|
|
237
241
|
"collapses to { file: null, url: null } with no round-trip; a deleted / " +
|
|
238
242
|
"not-found id degrades the same way (url stays null, error carries the " +
|
|
239
|
-
"wire error) so a display widget shows its fallback instead of crashing."
|
|
243
|
+
"wire error) so a display widget shows its fallback instead of crashing. " +
|
|
244
|
+
"READ THE TOP-LEVEL `url`: `file` is null until the fetch resolves (and " +
|
|
245
|
+
"stays null for an empty id), so `file.url` throws on the first render. " +
|
|
246
|
+
"Every file record the client returns does carry the same `url` alias, " +
|
|
247
|
+
"so `file.url` is correct once you have null-checked `file`. " +
|
|
248
|
+
"NEVER build a file URL by hand from an id — no route serves one.",
|
|
240
249
|
returnShape: {
|
|
241
250
|
file: "FilestoreFile | null",
|
|
242
251
|
url: "string | null",
|
|
@@ -295,10 +304,10 @@ const HOOKS = [
|
|
|
295
304
|
name: "useFilestoreFolders",
|
|
296
305
|
signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
|
|
297
306
|
description:
|
|
298
|
-
"Browse the end-user's Filestore folders in a project or
|
|
299
|
-
"mirroring useFilestoreFiles for subfolder navigation. The hook
|
|
300
|
-
"owner_id from the host context; pass enabled:false to suspend
|
|
301
|
-
"Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
|
|
307
|
+
"Browse the end-user's Filestore folders in a project, personal, or public " +
|
|
308
|
+
"space, mirroring useFilestoreFiles for subfolder navigation. The hook " +
|
|
309
|
+
"resolves owner_id from the host context; pass enabled:false to suspend " +
|
|
310
|
+
"fetching. Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
|
|
302
311
|
returnShape: {
|
|
303
312
|
folders: "FilestoreFolder[]",
|
|
304
313
|
loading: "boolean",
|
|
@@ -1090,7 +1099,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1090
1099
|
},
|
|
1091
1100
|
filestore: {
|
|
1092
1101
|
description:
|
|
1093
|
-
"Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal spaces) + BankID file signing. " +
|
|
1102
|
+
"Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal / public spaces) + BankID file signing. " +
|
|
1094
1103
|
"{ files: { list(query) -> Promise<{ data, meta }>, get(id), upload(formData), update(id, body), remove(id), preview(id) }, " +
|
|
1095
1104
|
"folders: { list, create, update, remove }, shares: { ... }, trash: { ... }, " +
|
|
1096
1105
|
"signatures: { initiate(fileId), status(id), cancel(id), verify(id) }, objectUrl(token), fetchObject(token) }. " +
|
|
@@ -1209,12 +1218,11 @@ const BUNDLE_EXPORT_CONTRACT = [
|
|
|
1209
1218
|
// REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
|
|
1210
1219
|
// §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. Widgets may call
|
|
1211
1220
|
// third-party APIs directly. Same-origin requests to the host's own
|
|
1212
|
-
// `/api/*` surface
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
// submission so authors learn the rule statically.
|
|
1221
|
+
// `/api/*` surface just fail: widget code never receives the JWT, so they
|
|
1222
|
+
// 401 (and an invented route like `/api/files/<id>` 404s). There is no
|
|
1223
|
+
// runtime gate — the soft linter warning (`no-host-api-url`) is the only
|
|
1224
|
+
// thing that catches it, which is why it flags relative `/api/` literals
|
|
1225
|
+
// too, not just the real `/api/v1` prefix.
|
|
1218
1226
|
const BANNED_APIS = [
|
|
1219
1227
|
{ identifier: "eval", reason: "Arbitrary code evaluation." },
|
|
1220
1228
|
{
|
|
@@ -1305,7 +1313,7 @@ const VETTED_IMPORTS = [
|
|
|
1305
1313
|
platforms: ["web", "native"],
|
|
1306
1314
|
category: "network",
|
|
1307
1315
|
description:
|
|
1308
|
-
"HTTP client for third-party APIs. Calls to the host's /api/* surface
|
|
1316
|
+
"HTTP client for third-party APIs. Calls to the host's own /api/* surface do not work — a widget is never given a JWT, so they 401 (and an invented path matches no route at all); use SDK hooks for workspace data.",
|
|
1309
1317
|
},
|
|
1310
1318
|
{
|
|
1311
1319
|
specifier: "date-fns",
|
|
@@ -1513,6 +1521,18 @@ const HOST_API_URL_PATTERNS = [
|
|
|
1513
1521
|
"/api/v1",
|
|
1514
1522
|
"/uploads/",
|
|
1515
1523
|
"Authorization: Bearer",
|
|
1524
|
+
// sc-3589 follow-up — the invented file route, matched ANYWHERE in a literal
|
|
1525
|
+
// so the origin-prefixed spelling (`${location.origin}/api/files/<id>`) is
|
|
1526
|
+
// caught as well as the bare relative one. No route serves this path.
|
|
1527
|
+
"/api/files/",
|
|
1528
|
+
// A QUOTED relative path into the host API — a broader net for invented
|
|
1529
|
+
// prefixes generally. Quote-anchored so an absolute third-party URL that
|
|
1530
|
+
// merely contains "/api/" (https://api.example.com/api/x) does not match.
|
|
1531
|
+
// Comments are blanked before matching, and a legitimate third-party
|
|
1532
|
+
// relative path can opt out with the `appstudio-lint-ignore` directive.
|
|
1533
|
+
'"/api/',
|
|
1534
|
+
"'/api/",
|
|
1535
|
+
"`/api/",
|
|
1516
1536
|
];
|
|
1517
1537
|
|
|
1518
1538
|
function deepFreeze(value) {
|
|
@@ -1926,7 +1946,21 @@ const CONTRACT = deepFreeze({
|
|
|
1926
1946
|
// Previously it hardcoded `color: inherit` / an unset scheme (web) and an
|
|
1927
1947
|
// uncoloured trigger (native), rendering dark-on-dark. No prop changed —
|
|
1928
1948
|
// behavioural fix, additive.
|
|
1929
|
-
|
|
1949
|
+
// 1.44.0 (sc-3589 follow-up) — two FILE-display fixes. (a) The filestore
|
|
1950
|
+
// client's `withDisplayableUrl` normalizer now aliases the absolutized presigned_url as `url` on
|
|
1951
|
+
// EVERY file record, so `file.url` works for `useFilestoreFile` AND every
|
|
1952
|
+
// row of `useFilestoreFiles`. The wire field is `presigned_url`; reading
|
|
1953
|
+
// the wrong name returned undefined silently, rendering nothing with no
|
|
1954
|
+
// error. Aliased in the one shared normalizer, not per hook, so the
|
|
1955
|
+
// single-file and list shapes cannot diverge. `file` is still null while
|
|
1956
|
+
// loading — the top-level `url` remains the read to prefer.
|
|
1957
|
+
// (b) `hostApiUrlPatterns` gains `/api/files/` (matched anywhere, so the
|
|
1958
|
+
// origin-prefixed spelling is caught) plus quote-anchored `/api/` needles
|
|
1959
|
+
// for invented prefixes generally. The rule now blanks COMMENTS before
|
|
1960
|
+
// matching, so a comment quoting the bad path is not a finding, and a
|
|
1961
|
+
// legitimate third-party relative path can opt out with an
|
|
1962
|
+
// `appstudio-lint-ignore no-host-api-url` comment. Additive.
|
|
1963
|
+
version: "1.44.0",
|
|
1930
1964
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
1931
1965
|
hooks: HOOKS,
|
|
1932
1966
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -211,10 +211,14 @@ const HOOKS = [
|
|
|
211
211
|
name: "useFilestoreFiles",
|
|
212
212
|
signature: "useFilestoreFiles({ spaceType, folderId?, q?, type? })",
|
|
213
213
|
description:
|
|
214
|
-
"Browse the end-user's Filestore files in a project or
|
|
215
|
-
"The hook resolves owner_id from the host context (tenant for
|
|
216
|
-
"app user for personal) — the widget only chooses the
|
|
217
|
-
"
|
|
214
|
+
"Browse the end-user's Filestore files in a project, personal, or public " +
|
|
215
|
+
"space. The hook resolves owner_id from the host context (tenant for " +
|
|
216
|
+
"project/public, app user for personal) — the widget only chooses the " +
|
|
217
|
+
"space. A public space is world-readable: its files render even for " +
|
|
218
|
+
"logged-out Player visitors. Reads ctx.filestore.files.list and unwraps " +
|
|
219
|
+
"{ data, meta } to the files array. Every row carries a ready-to-render " +
|
|
220
|
+
"`url` (its absolutized presigned_url, aliased by the filestore client) " +
|
|
221
|
+
"so a gallery renders `files.map(f => f.url)` directly.",
|
|
218
222
|
returnShape: {
|
|
219
223
|
files: "FilestoreFile[]",
|
|
220
224
|
loading: "boolean",
|
|
@@ -236,7 +240,12 @@ const HOOKS = [
|
|
|
236
240
|
"absolutized by the client so it loads on web AND native. An empty id " +
|
|
237
241
|
"collapses to { file: null, url: null } with no round-trip; a deleted / " +
|
|
238
242
|
"not-found id degrades the same way (url stays null, error carries the " +
|
|
239
|
-
"wire error) so a display widget shows its fallback instead of crashing."
|
|
243
|
+
"wire error) so a display widget shows its fallback instead of crashing. " +
|
|
244
|
+
"READ THE TOP-LEVEL `url`: `file` is null until the fetch resolves (and " +
|
|
245
|
+
"stays null for an empty id), so `file.url` throws on the first render. " +
|
|
246
|
+
"Every file record the client returns does carry the same `url` alias, " +
|
|
247
|
+
"so `file.url` is correct once you have null-checked `file`. " +
|
|
248
|
+
"NEVER build a file URL by hand from an id — no route serves one.",
|
|
240
249
|
returnShape: {
|
|
241
250
|
file: "FilestoreFile | null",
|
|
242
251
|
url: "string | null",
|
|
@@ -295,10 +304,10 @@ const HOOKS = [
|
|
|
295
304
|
name: "useFilestoreFolders",
|
|
296
305
|
signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
|
|
297
306
|
description:
|
|
298
|
-
"Browse the end-user's Filestore folders in a project or
|
|
299
|
-
"mirroring useFilestoreFiles for subfolder navigation. The hook
|
|
300
|
-
"owner_id from the host context; pass enabled:false to suspend
|
|
301
|
-
"Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
|
|
307
|
+
"Browse the end-user's Filestore folders in a project, personal, or public " +
|
|
308
|
+
"space, mirroring useFilestoreFiles for subfolder navigation. The hook " +
|
|
309
|
+
"resolves owner_id from the host context; pass enabled:false to suspend " +
|
|
310
|
+
"fetching. Reads ctx.filestore.folders.list and unwraps { data, meta } to the array.",
|
|
302
311
|
returnShape: {
|
|
303
312
|
folders: "FilestoreFolder[]",
|
|
304
313
|
loading: "boolean",
|
|
@@ -1090,7 +1099,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1090
1099
|
},
|
|
1091
1100
|
filestore: {
|
|
1092
1101
|
description:
|
|
1093
|
-
"Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal spaces) + BankID file signing. " +
|
|
1102
|
+
"Injected @colixsystems/filestore-client instance — the end-user file archive (project / personal / public spaces) + BankID file signing. " +
|
|
1094
1103
|
"{ files: { list(query) -> Promise<{ data, meta }>, get(id), upload(formData), update(id, body), remove(id), preview(id) }, " +
|
|
1095
1104
|
"folders: { list, create, update, remove }, shares: { ... }, trash: { ... }, " +
|
|
1096
1105
|
"signatures: { initiate(fileId), status(id), cancel(id), verify(id) }, objectUrl(token), fetchObject(token) }. " +
|
|
@@ -1209,12 +1218,11 @@ const BUNDLE_EXPORT_CONTRACT = [
|
|
|
1209
1218
|
// REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
|
|
1210
1219
|
// §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. Widgets may call
|
|
1211
1220
|
// third-party APIs directly. Same-origin requests to the host's own
|
|
1212
|
-
// `/api/*` surface
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
// submission so authors learn the rule statically.
|
|
1221
|
+
// `/api/*` surface just fail: widget code never receives the JWT, so they
|
|
1222
|
+
// 401 (and an invented route like `/api/files/<id>` 404s). There is no
|
|
1223
|
+
// runtime gate — the soft linter warning (`no-host-api-url`) is the only
|
|
1224
|
+
// thing that catches it, which is why it flags relative `/api/` literals
|
|
1225
|
+
// too, not just the real `/api/v1` prefix.
|
|
1218
1226
|
const BANNED_APIS = [
|
|
1219
1227
|
{ identifier: "eval", reason: "Arbitrary code evaluation." },
|
|
1220
1228
|
{
|
|
@@ -1305,7 +1313,7 @@ const VETTED_IMPORTS = [
|
|
|
1305
1313
|
platforms: ["web", "native"],
|
|
1306
1314
|
category: "network",
|
|
1307
1315
|
description:
|
|
1308
|
-
"HTTP client for third-party APIs. Calls to the host's /api/* surface
|
|
1316
|
+
"HTTP client for third-party APIs. Calls to the host's own /api/* surface do not work — a widget is never given a JWT, so they 401 (and an invented path matches no route at all); use SDK hooks for workspace data.",
|
|
1309
1317
|
},
|
|
1310
1318
|
{
|
|
1311
1319
|
specifier: "date-fns",
|
|
@@ -1513,6 +1521,18 @@ const HOST_API_URL_PATTERNS = [
|
|
|
1513
1521
|
"/api/v1",
|
|
1514
1522
|
"/uploads/",
|
|
1515
1523
|
"Authorization: Bearer",
|
|
1524
|
+
// sc-3589 follow-up — the invented file route, matched ANYWHERE in a literal
|
|
1525
|
+
// so the origin-prefixed spelling (`${location.origin}/api/files/<id>`) is
|
|
1526
|
+
// caught as well as the bare relative one. No route serves this path.
|
|
1527
|
+
"/api/files/",
|
|
1528
|
+
// A QUOTED relative path into the host API — a broader net for invented
|
|
1529
|
+
// prefixes generally. Quote-anchored so an absolute third-party URL that
|
|
1530
|
+
// merely contains "/api/" (https://api.example.com/api/x) does not match.
|
|
1531
|
+
// Comments are blanked before matching, and a legitimate third-party
|
|
1532
|
+
// relative path can opt out with the `appstudio-lint-ignore` directive.
|
|
1533
|
+
'"/api/',
|
|
1534
|
+
"'/api/",
|
|
1535
|
+
"`/api/",
|
|
1516
1536
|
];
|
|
1517
1537
|
|
|
1518
1538
|
function deepFreeze(value) {
|
|
@@ -1926,7 +1946,21 @@ const CONTRACT = deepFreeze({
|
|
|
1926
1946
|
// Previously it hardcoded `color: inherit` / an unset scheme (web) and an
|
|
1927
1947
|
// uncoloured trigger (native), rendering dark-on-dark. No prop changed —
|
|
1928
1948
|
// behavioural fix, additive.
|
|
1929
|
-
|
|
1949
|
+
// 1.44.0 (sc-3589 follow-up) — two FILE-display fixes. (a) The filestore
|
|
1950
|
+
// client's `withDisplayableUrl` normalizer now aliases the absolutized presigned_url as `url` on
|
|
1951
|
+
// EVERY file record, so `file.url` works for `useFilestoreFile` AND every
|
|
1952
|
+
// row of `useFilestoreFiles`. The wire field is `presigned_url`; reading
|
|
1953
|
+
// the wrong name returned undefined silently, rendering nothing with no
|
|
1954
|
+
// error. Aliased in the one shared normalizer, not per hook, so the
|
|
1955
|
+
// single-file and list shapes cannot diverge. `file` is still null while
|
|
1956
|
+
// loading — the top-level `url` remains the read to prefer.
|
|
1957
|
+
// (b) `hostApiUrlPatterns` gains `/api/files/` (matched anywhere, so the
|
|
1958
|
+
// origin-prefixed spelling is caught) plus quote-anchored `/api/` needles
|
|
1959
|
+
// for invented prefixes generally. The rule now blanks COMMENTS before
|
|
1960
|
+
// matching, so a comment quoting the bad path is not a finding, and a
|
|
1961
|
+
// legitimate third-party relative path can opt out with an
|
|
1962
|
+
// `appstudio-lint-ignore no-host-api-url` comment. Additive.
|
|
1963
|
+
version: "1.44.0",
|
|
1930
1964
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
1931
1965
|
hooks: HOOKS,
|
|
1932
1966
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -1449,8 +1449,10 @@ export function useAssetsByTag(tag, options) {
|
|
|
1449
1449
|
* ==========================================================================*/
|
|
1450
1450
|
|
|
1451
1451
|
// Resolve the owner_id a filestore query needs from the host context: a PROJECT
|
|
1452
|
-
// space is owned by the tenant (ctx.workspace.id); a PERSONAL space is
|
|
1453
|
-
// the signed-in app user (ctx.user.id). The widget only chooses the
|
|
1452
|
+
// or PUBLIC space is owned by the tenant (ctx.workspace.id); a PERSONAL space is
|
|
1453
|
+
// owned by the signed-in app user (ctx.user.id). The widget only chooses the
|
|
1454
|
+
// space. PUBLIC is world-readable, so its files resolve even when there is no
|
|
1455
|
+
// signed-in user (ctx.workspace.id is set for a logged-out Player visitor too).
|
|
1454
1456
|
function _filestoreOwnerId(ctx, spaceType) {
|
|
1455
1457
|
const space = String(spaceType || "project").toUpperCase();
|
|
1456
1458
|
if (space === "PERSONAL") return (ctx.user && ctx.user.id) || null;
|
|
@@ -1546,7 +1548,8 @@ export function useFilestoreFiles(options) {
|
|
|
1546
1548
|
* An empty / null id collapses to `{ file: null, url: null }` with no network
|
|
1547
1549
|
* round-trip; a deleted or not-found id degrades the same way (`url` stays
|
|
1548
1550
|
* null and `error` carries the wire error) so a display widget shows its
|
|
1549
|
-
* fallback instead of crashing.
|
|
1551
|
+
* fallback instead of crashing. Read the top-level `url` — `file` is null
|
|
1552
|
+
* until the fetch resolves, so `file.url` throws on the first render.
|
|
1550
1553
|
*/
|
|
1551
1554
|
export function useFilestoreFile(fileId) {
|
|
1552
1555
|
const ctx = useWidgetContextOrThrow("useFilestoreFile");
|
package/dist/linter.cjs
CHANGED
|
@@ -69,7 +69,7 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
|
69
69
|
// left intact: real code lives there and must still be scanned (`${window}`
|
|
70
70
|
// is a genuine escape). Backslash escapes inside strings/templates are
|
|
71
71
|
// consumed so an escaped quote (`"\""`) doesn't end the literal early.
|
|
72
|
-
function _stripNonCode(source) {
|
|
72
|
+
function _stripNonCode(source, { keepStrings = false } = {}) {
|
|
73
73
|
let out = "";
|
|
74
74
|
const n = source.length;
|
|
75
75
|
let mode = "code"; // code | line | block | sq | dq | tmpl
|
|
@@ -84,6 +84,10 @@ function _stripNonCode(source) {
|
|
|
84
84
|
const blank = (ch) => {
|
|
85
85
|
out += ch === "\n" || ch === "\r" ? ch : " ";
|
|
86
86
|
};
|
|
87
|
+
// String / template CONTENT: blanked for the banned-identifier scan
|
|
88
|
+
// (prose must not trip `no-window`), kept for the host-API-URL scan,
|
|
89
|
+
// whose whole job is to find a URL literal.
|
|
90
|
+
const str = keepStrings ? keep : blank;
|
|
87
91
|
let i = 0;
|
|
88
92
|
while (i < n) {
|
|
89
93
|
const ch = source[i];
|
|
@@ -101,15 +105,15 @@ function _stripNonCode(source) {
|
|
|
101
105
|
i += 2;
|
|
102
106
|
} else if (ch === "'") {
|
|
103
107
|
mode = "sq";
|
|
104
|
-
|
|
108
|
+
str(ch);
|
|
105
109
|
i += 1;
|
|
106
110
|
} else if (ch === '"') {
|
|
107
111
|
mode = "dq";
|
|
108
|
-
|
|
112
|
+
str(ch);
|
|
109
113
|
i += 1;
|
|
110
114
|
} else if (ch === "`") {
|
|
111
115
|
mode = "tmpl";
|
|
112
|
-
|
|
116
|
+
str(ch);
|
|
113
117
|
i += 1;
|
|
114
118
|
} else if (ch === "{") {
|
|
115
119
|
braceDepth += 1;
|
|
@@ -123,7 +127,7 @@ function _stripNonCode(source) {
|
|
|
123
127
|
) {
|
|
124
128
|
tmplStack.pop();
|
|
125
129
|
mode = "tmpl";
|
|
126
|
-
|
|
130
|
+
str(ch);
|
|
127
131
|
} else {
|
|
128
132
|
keep(ch);
|
|
129
133
|
}
|
|
@@ -153,12 +157,12 @@ function _stripNonCode(source) {
|
|
|
153
157
|
} else if (mode === "sq" || mode === "dq") {
|
|
154
158
|
const quote = mode === "sq" ? "'" : '"';
|
|
155
159
|
if (ch === "\\") {
|
|
156
|
-
|
|
157
|
-
if (i + 1 < n)
|
|
160
|
+
str(ch);
|
|
161
|
+
if (i + 1 < n) str(nx);
|
|
158
162
|
i += 2;
|
|
159
163
|
} else if (ch === quote) {
|
|
160
164
|
mode = "code";
|
|
161
|
-
|
|
165
|
+
str(ch);
|
|
162
166
|
i += 1;
|
|
163
167
|
} else if (ch === "\n") {
|
|
164
168
|
// A bare newline terminates an unterminated string in JS; bail back
|
|
@@ -167,18 +171,18 @@ function _stripNonCode(source) {
|
|
|
167
171
|
keep(ch);
|
|
168
172
|
i += 1;
|
|
169
173
|
} else {
|
|
170
|
-
|
|
174
|
+
str(ch);
|
|
171
175
|
i += 1;
|
|
172
176
|
}
|
|
173
177
|
} else {
|
|
174
178
|
// mode === "tmpl"
|
|
175
179
|
if (ch === "\\") {
|
|
176
|
-
|
|
177
|
-
if (i + 1 < n)
|
|
180
|
+
str(ch);
|
|
181
|
+
if (i + 1 < n) str(nx);
|
|
178
182
|
i += 2;
|
|
179
183
|
} else if (ch === "`") {
|
|
180
184
|
mode = "code";
|
|
181
|
-
|
|
185
|
+
str(ch);
|
|
182
186
|
i += 1;
|
|
183
187
|
} else if (ch === "$" && nx === "{") {
|
|
184
188
|
// Enter an expression hole. Remember the brace depth the template
|
|
@@ -190,7 +194,7 @@ function _stripNonCode(source) {
|
|
|
190
194
|
keep(nx);
|
|
191
195
|
i += 2;
|
|
192
196
|
} else {
|
|
193
|
-
|
|
197
|
+
str(ch);
|
|
194
198
|
i += 1;
|
|
195
199
|
}
|
|
196
200
|
}
|
|
@@ -332,11 +336,37 @@ function _importRules(source, manifest) {
|
|
|
332
336
|
return findings;
|
|
333
337
|
}
|
|
334
338
|
|
|
339
|
+
// Explicit opt-out. A widget legitimately calling a THIRD-PARTY API with a
|
|
340
|
+
// relative path against an `axios` baseURL cannot avoid the substring match,
|
|
341
|
+
// and post-sc-3589 an unavoidable finding burns AI repair turns on correct
|
|
342
|
+
// code. The directive may sit on the offending line or the line above it.
|
|
343
|
+
const HOST_API_URL_IGNORE = "appstudio-lint-ignore no-host-api-url";
|
|
344
|
+
|
|
345
|
+
function _hostApiUrlIgnoredLines(source) {
|
|
346
|
+
const ignored = new Set();
|
|
347
|
+
const lines = source.split(/\r?\n/);
|
|
348
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
349
|
+
const line = lines[i];
|
|
350
|
+
if (!line.includes(HOST_API_URL_IGNORE)) continue;
|
|
351
|
+
ignored.add(i + 1);
|
|
352
|
+
// A directive on its OWN line also exempts the line below it. A TRAILING
|
|
353
|
+
// comment exempts only its own line — otherwise it would silently cover
|
|
354
|
+
// the next statement, which may be a genuine hand-built host URL.
|
|
355
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) ignored.add(i + 2);
|
|
356
|
+
}
|
|
357
|
+
return ignored;
|
|
358
|
+
}
|
|
359
|
+
|
|
335
360
|
function _hostApiUrlRules(source) {
|
|
336
361
|
const findings = [];
|
|
337
362
|
const patterns = CONTRACT.hostApiUrlPatterns || [];
|
|
338
|
-
const
|
|
363
|
+
const ignored = _hostApiUrlIgnoredLines(source);
|
|
364
|
+
// Comments are blanked first: a comment or JSDoc line quoting
|
|
365
|
+
// `/api/files/<id>` — exactly the text the docs tell authors NOT to write —
|
|
366
|
+
// must not become a blocking publish check.
|
|
367
|
+
const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
|
|
339
368
|
for (let i = 0; i < lines.length; i += 1) {
|
|
369
|
+
if (ignored.has(i + 1)) continue;
|
|
340
370
|
const line = lines[i];
|
|
341
371
|
for (const needle of patterns) {
|
|
342
372
|
if (line.includes(needle)) {
|
|
@@ -344,9 +374,14 @@ function _hostApiUrlRules(source) {
|
|
|
344
374
|
rule: "no-host-api-url",
|
|
345
375
|
severity: "warning",
|
|
346
376
|
label:
|
|
347
|
-
`source contains "${needle}" —
|
|
348
|
-
`
|
|
349
|
-
`
|
|
377
|
+
`source contains "${needle}" — a widget cannot reach the ` +
|
|
378
|
+
`AppStudio host API: it is never given a JWT, so the call 401s, ` +
|
|
379
|
+
`and an invented path like /api/files/<id> matches no route at ` +
|
|
380
|
+
`all. Read workspace data through SDK hooks (a stored file via ` +
|
|
381
|
+
`useFilestoreFile(id).url — never a URL you build yourself). ` +
|
|
382
|
+
`\`axios\`/\`fetch\` remain fine for THIRD-PARTY APIs; if one ` +
|
|
383
|
+
`genuinely needs a relative /api path, add the comment ` +
|
|
384
|
+
`"${HOST_API_URL_IGNORE}" on or above the line.`,
|
|
350
385
|
line: i + 1,
|
|
351
386
|
snippet: line.trim().slice(0, 200),
|
|
352
387
|
});
|
|
@@ -634,6 +669,64 @@ function _reactInScopeRules(source) {
|
|
|
634
669
|
return findings;
|
|
635
670
|
}
|
|
636
671
|
|
|
672
|
+
// sc-3466 / sc-3493 — percentage height on an <Image> collapses to 0 against a
|
|
673
|
+
// content-sized parent, so the image loads but renders invisible on both hosts.
|
|
674
|
+
// `severity: "warning"`: the same value is correct under a definite-height
|
|
675
|
+
// parent, which this AST-free scan cannot see. Mirror of linter.js.
|
|
676
|
+
const _IMAGE_TAG_RE = /<(Image|ImageBackground)\b/g;
|
|
677
|
+
const _PERCENT_HEIGHT_RE =
|
|
678
|
+
/(^|[{,;\s])height\s*:\s*(["'])\s*\d+(?:\.\d+)?\s*%\s*\2/g;
|
|
679
|
+
|
|
680
|
+
function _jsxOpenTagEnd(source, from) {
|
|
681
|
+
let depth = 0;
|
|
682
|
+
let quote = "";
|
|
683
|
+
for (let i = from; i < source.length; i += 1) {
|
|
684
|
+
const ch = source[i];
|
|
685
|
+
if (quote) {
|
|
686
|
+
if (ch === "\\") i += 1;
|
|
687
|
+
else if (ch === quote) quote = "";
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
691
|
+
else if (ch === "{") depth += 1;
|
|
692
|
+
else if (ch === "}") depth -= 1;
|
|
693
|
+
else if (ch === ">" && depth <= 0) return i;
|
|
694
|
+
}
|
|
695
|
+
return source.length;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function _imagePercentHeightRules(source) {
|
|
699
|
+
const findings = [];
|
|
700
|
+
const code = _stripNonCode(source, { keepStrings: true });
|
|
701
|
+
const sourceLines = source.split(/\r?\n/);
|
|
702
|
+
_IMAGE_TAG_RE.lastIndex = 0;
|
|
703
|
+
let tag;
|
|
704
|
+
while ((tag = _IMAGE_TAG_RE.exec(code))) {
|
|
705
|
+
const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
|
|
706
|
+
const attrs = code.slice(tag.index, end);
|
|
707
|
+
_PERCENT_HEIGHT_RE.lastIndex = 0;
|
|
708
|
+
const hit = _PERCENT_HEIGHT_RE.exec(attrs);
|
|
709
|
+
if (!hit) continue;
|
|
710
|
+
const line = code.slice(0, tag.index + hit.index).split(/\r?\n/).length;
|
|
711
|
+
findings.push({
|
|
712
|
+
rule: "image-percent-height",
|
|
713
|
+
severity: "warning",
|
|
714
|
+
label:
|
|
715
|
+
`<${tag[1]}> sizes its height with a percentage — React Native ` +
|
|
716
|
+
`resolves that against the PARENT's height, and a content-sized ` +
|
|
717
|
+
`parent has none, so it collapses to 0: the image loads but is ` +
|
|
718
|
+
`invisible on BOTH the web Player and the native Expo export. Size ` +
|
|
719
|
+
`it with aspectRatio (e.g. { width: "100%", aspectRatio: 1 }) or a ` +
|
|
720
|
+
`numeric pixel height. Warning only — a percentage height is correct ` +
|
|
721
|
+
`when the parent has a definite height (e.g. a fixed-height hero).`,
|
|
722
|
+
line,
|
|
723
|
+
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
724
|
+
});
|
|
725
|
+
_IMAGE_TAG_RE.lastIndex = end;
|
|
726
|
+
}
|
|
727
|
+
return findings;
|
|
728
|
+
}
|
|
729
|
+
|
|
637
730
|
// Narrow a split-impl widget's manifest to the platform a single bundle file
|
|
638
731
|
// ships to, so `import-platform-mismatch` lints each file against what it
|
|
639
732
|
// actually targets. Mirror of linter.js.
|
|
@@ -694,6 +787,7 @@ function lintSource(source, options) {
|
|
|
694
787
|
findings.push(..._hostApiUrlRules(source));
|
|
695
788
|
findings.push(..._lucideIconRules(source));
|
|
696
789
|
findings.push(..._reactInScopeRules(source));
|
|
790
|
+
findings.push(..._imagePercentHeightRules(source));
|
|
697
791
|
findings.push(
|
|
698
792
|
..._scopeRules(source, options && options.manifest).map((f) => ({
|
|
699
793
|
...f,
|
package/dist/linter.js
CHANGED
|
@@ -70,7 +70,7 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
|
70
70
|
// left intact: real code lives there and must still be scanned (`${window}`
|
|
71
71
|
// is a genuine escape). Backslash escapes inside strings/templates are
|
|
72
72
|
// consumed so an escaped quote (`"\""`) doesn't end the literal early.
|
|
73
|
-
function _stripNonCode(source) {
|
|
73
|
+
function _stripNonCode(source, { keepStrings = false } = {}) {
|
|
74
74
|
let out = "";
|
|
75
75
|
const n = source.length;
|
|
76
76
|
let mode = "code"; // code | line | block | sq | dq | tmpl
|
|
@@ -85,6 +85,10 @@ function _stripNonCode(source) {
|
|
|
85
85
|
const blank = (ch) => {
|
|
86
86
|
out += ch === "\n" || ch === "\r" ? ch : " ";
|
|
87
87
|
};
|
|
88
|
+
// String / template CONTENT: blanked for the banned-identifier scan
|
|
89
|
+
// (prose must not trip `no-window`), kept for the host-API-URL scan,
|
|
90
|
+
// whose whole job is to find a URL literal.
|
|
91
|
+
const str = keepStrings ? keep : blank;
|
|
88
92
|
let i = 0;
|
|
89
93
|
while (i < n) {
|
|
90
94
|
const ch = source[i];
|
|
@@ -102,15 +106,15 @@ function _stripNonCode(source) {
|
|
|
102
106
|
i += 2;
|
|
103
107
|
} else if (ch === "'") {
|
|
104
108
|
mode = "sq";
|
|
105
|
-
|
|
109
|
+
str(ch);
|
|
106
110
|
i += 1;
|
|
107
111
|
} else if (ch === '"') {
|
|
108
112
|
mode = "dq";
|
|
109
|
-
|
|
113
|
+
str(ch);
|
|
110
114
|
i += 1;
|
|
111
115
|
} else if (ch === "`") {
|
|
112
116
|
mode = "tmpl";
|
|
113
|
-
|
|
117
|
+
str(ch);
|
|
114
118
|
i += 1;
|
|
115
119
|
} else if (ch === "{") {
|
|
116
120
|
braceDepth += 1;
|
|
@@ -124,7 +128,7 @@ function _stripNonCode(source) {
|
|
|
124
128
|
) {
|
|
125
129
|
tmplStack.pop();
|
|
126
130
|
mode = "tmpl";
|
|
127
|
-
|
|
131
|
+
str(ch);
|
|
128
132
|
} else {
|
|
129
133
|
keep(ch);
|
|
130
134
|
}
|
|
@@ -154,12 +158,12 @@ function _stripNonCode(source) {
|
|
|
154
158
|
} else if (mode === "sq" || mode === "dq") {
|
|
155
159
|
const quote = mode === "sq" ? "'" : '"';
|
|
156
160
|
if (ch === "\\") {
|
|
157
|
-
|
|
158
|
-
if (i + 1 < n)
|
|
161
|
+
str(ch);
|
|
162
|
+
if (i + 1 < n) str(nx);
|
|
159
163
|
i += 2;
|
|
160
164
|
} else if (ch === quote) {
|
|
161
165
|
mode = "code";
|
|
162
|
-
|
|
166
|
+
str(ch);
|
|
163
167
|
i += 1;
|
|
164
168
|
} else if (ch === "\n") {
|
|
165
169
|
// A bare newline terminates an unterminated string in JS; bail back
|
|
@@ -168,18 +172,18 @@ function _stripNonCode(source) {
|
|
|
168
172
|
keep(ch);
|
|
169
173
|
i += 1;
|
|
170
174
|
} else {
|
|
171
|
-
|
|
175
|
+
str(ch);
|
|
172
176
|
i += 1;
|
|
173
177
|
}
|
|
174
178
|
} else {
|
|
175
179
|
// mode === "tmpl"
|
|
176
180
|
if (ch === "\\") {
|
|
177
|
-
|
|
178
|
-
if (i + 1 < n)
|
|
181
|
+
str(ch);
|
|
182
|
+
if (i + 1 < n) str(nx);
|
|
179
183
|
i += 2;
|
|
180
184
|
} else if (ch === "`") {
|
|
181
185
|
mode = "code";
|
|
182
|
-
|
|
186
|
+
str(ch);
|
|
183
187
|
i += 1;
|
|
184
188
|
} else if (ch === "$" && nx === "{") {
|
|
185
189
|
// Enter an expression hole. Remember the brace depth the template
|
|
@@ -191,7 +195,7 @@ function _stripNonCode(source) {
|
|
|
191
195
|
keep(nx);
|
|
192
196
|
i += 2;
|
|
193
197
|
} else {
|
|
194
|
-
|
|
198
|
+
str(ch);
|
|
195
199
|
i += 1;
|
|
196
200
|
}
|
|
197
201
|
}
|
|
@@ -204,10 +208,10 @@ function _stripNonCode(source) {
|
|
|
204
208
|
//
|
|
205
209
|
// REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
|
|
206
210
|
// import list now (`CONTRACT.vettedImports`) — widgets may call third-party
|
|
207
|
-
// APIs directly. Calls to the host's own /api/* surface are
|
|
208
|
-
// runtime
|
|
209
|
-
// `no-host-api-url` rule below
|
|
210
|
-
//
|
|
211
|
+
// APIs directly. Calls to the host's own /api/* surface are not gated at
|
|
212
|
+
// runtime — they simply 401 (widget code never gets the JWT), so the soft
|
|
213
|
+
// `no-host-api-url` rule below is the only thing that catches them, and it
|
|
214
|
+
// flags relative `/api/` literals as well as the real `/api/v1` prefix.
|
|
211
215
|
const EXTRA_RULES = [
|
|
212
216
|
{
|
|
213
217
|
id: "no-auth-store-import",
|
|
@@ -369,14 +373,43 @@ function _importRules(source, manifest) {
|
|
|
369
373
|
}
|
|
370
374
|
|
|
371
375
|
// REQ-WSDK-PLATFORM §3.5: soft warning when source contains host-API URL
|
|
372
|
-
// substrings.
|
|
373
|
-
//
|
|
374
|
-
//
|
|
376
|
+
// substrings. `severity: "warning"` so a HUMAN-authored submission still
|
|
377
|
+
// publishes and the marketplace review queue flags it for a human pass.
|
|
378
|
+
// sc-3589 — an AI-agent widget has no such review queue, so its publish loop
|
|
379
|
+
// treats every finding (this one included) as a blocking check that drives a
|
|
380
|
+
// repair turn; a lone unrepaired one still publishes with a warning.
|
|
381
|
+
// False positives are possible: the needles are plain substring matches.
|
|
382
|
+
// Explicit opt-out. A widget legitimately calling a THIRD-PARTY API with a
|
|
383
|
+
// relative path against an `axios` baseURL cannot avoid the substring match,
|
|
384
|
+
// and post-sc-3589 an unavoidable finding burns AI repair turns on correct
|
|
385
|
+
// code. The directive may sit on the offending line or the line above it.
|
|
386
|
+
const HOST_API_URL_IGNORE = "appstudio-lint-ignore no-host-api-url";
|
|
387
|
+
|
|
388
|
+
function _hostApiUrlIgnoredLines(source) {
|
|
389
|
+
const ignored = new Set();
|
|
390
|
+
const lines = source.split(/\r?\n/);
|
|
391
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
392
|
+
const line = lines[i];
|
|
393
|
+
if (!line.includes(HOST_API_URL_IGNORE)) continue;
|
|
394
|
+
ignored.add(i + 1);
|
|
395
|
+
// A directive on its OWN line also exempts the line below it. A TRAILING
|
|
396
|
+
// comment exempts only its own line — otherwise it would silently cover
|
|
397
|
+
// the next statement, which may be a genuine hand-built host URL.
|
|
398
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) ignored.add(i + 2);
|
|
399
|
+
}
|
|
400
|
+
return ignored;
|
|
401
|
+
}
|
|
402
|
+
|
|
375
403
|
function _hostApiUrlRules(source) {
|
|
376
404
|
const findings = [];
|
|
377
405
|
const patterns = CONTRACT.hostApiUrlPatterns || [];
|
|
378
|
-
const
|
|
406
|
+
const ignored = _hostApiUrlIgnoredLines(source);
|
|
407
|
+
// Comments are blanked first: a comment or JSDoc line quoting
|
|
408
|
+
// `/api/files/<id>` — exactly the text the docs tell authors NOT to write —
|
|
409
|
+
// must not become a blocking publish check.
|
|
410
|
+
const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
|
|
379
411
|
for (let i = 0; i < lines.length; i += 1) {
|
|
412
|
+
if (ignored.has(i + 1)) continue;
|
|
380
413
|
const line = lines[i];
|
|
381
414
|
for (const needle of patterns) {
|
|
382
415
|
if (line.includes(needle)) {
|
|
@@ -384,9 +417,14 @@ function _hostApiUrlRules(source) {
|
|
|
384
417
|
rule: "no-host-api-url",
|
|
385
418
|
severity: "warning",
|
|
386
419
|
label:
|
|
387
|
-
`source contains "${needle}" —
|
|
388
|
-
`
|
|
389
|
-
`
|
|
420
|
+
`source contains "${needle}" — a widget cannot reach the ` +
|
|
421
|
+
`AppStudio host API: it is never given a JWT, so the call 401s, ` +
|
|
422
|
+
`and an invented path like /api/files/<id> matches no route at ` +
|
|
423
|
+
`all. Read workspace data through SDK hooks (a stored file via ` +
|
|
424
|
+
`useFilestoreFile(id).url — never a URL you build yourself). ` +
|
|
425
|
+
`\`axios\`/\`fetch\` remain fine for THIRD-PARTY APIs; if one ` +
|
|
426
|
+
`genuinely needs a relative /api path, add the comment ` +
|
|
427
|
+
`"${HOST_API_URL_IGNORE}" on or above the line.`,
|
|
390
428
|
line: i + 1,
|
|
391
429
|
snippet: line.trim().slice(0, 200),
|
|
392
430
|
});
|
|
@@ -723,6 +761,82 @@ function _reactInScopeRules(source) {
|
|
|
723
761
|
return findings;
|
|
724
762
|
}
|
|
725
763
|
|
|
764
|
+
// sc-3466 / sc-3493 — percentage height on an <Image> collapses to 0.
|
|
765
|
+
// React Native / Yoga resolves a percentage `height` against the PARENT's
|
|
766
|
+
// height; a content-sized parent has none, so the value resolves to 0 and the
|
|
767
|
+
// image fetches its uri but renders invisible on BOTH the web Player and the
|
|
768
|
+
// native Expo export. sc-3466 taught the rule to the AI widget agent's
|
|
769
|
+
// DEFAULT_SYSTEM_PROMPT, which remains the primary guard — this is the
|
|
770
|
+
// mechanical belt-and-braces catch for a model (or a human author) that
|
|
771
|
+
// ignores it.
|
|
772
|
+
//
|
|
773
|
+
// `severity: "warning"` deliberately: `height: "100%"` IS correct inside a
|
|
774
|
+
// parent with a definite height (a fixed-height hero), which the AST-free scan
|
|
775
|
+
// cannot see, so a blocking rule would reject valid widgets.
|
|
776
|
+
//
|
|
777
|
+
// Scope is the literal inline form only. A height threaded through a variable
|
|
778
|
+
// or a StyleSheet object stays out of reach of a text scan.
|
|
779
|
+
const _IMAGE_TAG_RE = /<(Image|ImageBackground)\b/g;
|
|
780
|
+
const _PERCENT_HEIGHT_RE =
|
|
781
|
+
/(^|[{,;\s])height\s*:\s*(["'])\s*\d+(?:\.\d+)?\s*%\s*\2/g;
|
|
782
|
+
|
|
783
|
+
// Index of the `>` closing the JSX opening tag that starts at `from`. Braces
|
|
784
|
+
// and string literals are skipped so a `>` inside `onPress={() => …}` or an
|
|
785
|
+
// attribute string can't end the tag early.
|
|
786
|
+
function _jsxOpenTagEnd(source, from) {
|
|
787
|
+
let depth = 0;
|
|
788
|
+
let quote = "";
|
|
789
|
+
for (let i = from; i < source.length; i += 1) {
|
|
790
|
+
const ch = source[i];
|
|
791
|
+
if (quote) {
|
|
792
|
+
if (ch === "\\") i += 1;
|
|
793
|
+
else if (ch === quote) quote = "";
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
797
|
+
else if (ch === "{") depth += 1;
|
|
798
|
+
else if (ch === "}") depth -= 1;
|
|
799
|
+
else if (ch === ">" && depth <= 0) return i;
|
|
800
|
+
}
|
|
801
|
+
return source.length;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function _imagePercentHeightRules(source) {
|
|
805
|
+
const findings = [];
|
|
806
|
+
// Comments are blanked (string contents kept) so a commented-out example —
|
|
807
|
+
// including the one in this rule's own docs — is never flagged.
|
|
808
|
+
const code = _stripNonCode(source, { keepStrings: true });
|
|
809
|
+
const sourceLines = source.split(/\r?\n/);
|
|
810
|
+
_IMAGE_TAG_RE.lastIndex = 0;
|
|
811
|
+
let tag;
|
|
812
|
+
while ((tag = _IMAGE_TAG_RE.exec(code))) {
|
|
813
|
+
const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
|
|
814
|
+
const attrs = code.slice(tag.index, end);
|
|
815
|
+
_PERCENT_HEIGHT_RE.lastIndex = 0;
|
|
816
|
+
const hit = _PERCENT_HEIGHT_RE.exec(attrs);
|
|
817
|
+
if (!hit) continue;
|
|
818
|
+
const line = code.slice(0, tag.index + hit.index).split(/\r?\n/).length;
|
|
819
|
+
findings.push({
|
|
820
|
+
rule: "image-percent-height",
|
|
821
|
+
severity: "warning",
|
|
822
|
+
label:
|
|
823
|
+
`<${tag[1]}> sizes its height with a percentage — React Native ` +
|
|
824
|
+
`resolves that against the PARENT's height, and a content-sized ` +
|
|
825
|
+
`parent has none, so it collapses to 0: the image loads but is ` +
|
|
826
|
+
`invisible on BOTH the web Player and the native Expo export. Size ` +
|
|
827
|
+
`it with aspectRatio (e.g. { width: "100%", aspectRatio: 1 }) or a ` +
|
|
828
|
+
`numeric pixel height. Warning only — a percentage height is correct ` +
|
|
829
|
+
`when the parent has a definite height (e.g. a fixed-height hero).`,
|
|
830
|
+
line,
|
|
831
|
+
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
832
|
+
});
|
|
833
|
+
// One finding per <Image>; a second percentage height on the same tag is
|
|
834
|
+
// the same defect.
|
|
835
|
+
_IMAGE_TAG_RE.lastIndex = end;
|
|
836
|
+
}
|
|
837
|
+
return findings;
|
|
838
|
+
}
|
|
839
|
+
|
|
726
840
|
/**
|
|
727
841
|
* Narrow a split-impl widget's manifest to the platform a single bundle file
|
|
728
842
|
* ships to, so `import-platform-mismatch` lints each file against what it
|
|
@@ -790,6 +904,8 @@ export function lintSource(source, options) {
|
|
|
790
904
|
findings.push(..._lucideIconRules(source));
|
|
791
905
|
// sc-2353 — widget source must be self-contained (reference React ⇒ import it).
|
|
792
906
|
findings.push(..._reactInScopeRules(source));
|
|
907
|
+
// sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
|
|
908
|
+
findings.push(..._imagePercentHeightRules(source));
|
|
793
909
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
|
|
794
910
|
// line-by-line scan so banned-identifier findings stay first in the
|
|
795
911
|
// output.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node scripts/build.js",
|
|
51
|
-
"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-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js"
|
|
51
|
+
"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-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|