@colixsystems/widget-sdk 0.197.0 → 0.199.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 +148 -3
- package/dist/avatar-color.js +35 -0
- package/dist/bundled-assets-fs.js +60 -0
- package/dist/bundled-assets.cjs +239 -0
- package/dist/bundled-assets.js +239 -0
- package/dist/cli.js +52 -11
- package/dist/contract.cjs +225 -26
- package/dist/contract.js +225 -26
- package/dist/detail-parts-view.js +9 -4
- package/dist/devserver.js +68 -10
- package/dist/hooks.js +549 -28
- package/dist/index.d.ts +247 -5
- package/dist/index.js +14 -0
- package/dist/index.native.js +14 -0
- package/dist/initials.js +3 -2
- package/dist/linter.cjs +28 -0
- package/dist/linter.js +28 -0
- package/dist/manifest.cjs +3 -1
- package/dist/manifest.js +3 -1
- package/dist/overlay-view.js +2 -5
- package/dist/overlay.js +12 -5
- package/dist/primitives.js +2 -0
- package/dist/primitives.native.js +2 -0
- package/dist/video.js +51 -0
- package/dist/video.native.js +61 -0
- package/dist/web-modal.js +139 -0
- package/package.json +11 -1
- package/src/contract.js +225 -26
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
9
9
|
| `ctx.datastore` | `@colixsystems/datastore-client` | `tables.{list,get}`, `schema(tableId)`, `myPermissions(tableId, { recordId? })`, `records(tableId).{ list(query), get(id), create(values), update(id,values) [PATCH], delete(id), aggregate(spec), permissions(recordId).{ list, grant, update, revoke } }` |
|
|
10
10
|
| `ctx.directory` | `@colixsystems/directory-client` | `me()`, `users.{list,get,invite,deactivate,reactivate}`, `groups.{list,create,remove,addMember,removeMember,listMine}`, `invites.{list,revoke,resend}` |
|
|
11
11
|
| `ctx.assets` | `@colixsystems/assets-client` | the Asset Manager: `get(id)`, `list(query)`, `upload(formData)` over `/files` — what `useAsset()` (single asset by id) and `useAssetsByTag()` (every asset carrying a tag) resolve |
|
|
12
|
+
| `ctx.widget.assets` | — (built by the host) | the files the widget package bundles: a map of file name → absolute URL, `{}` when it bundles nothing — what `useBundledAsset()` resolves |
|
|
12
13
|
| `ctx.payments` | `@colixsystems/payments-client` | `requestPayment(body)`, `getPayment(id)` |
|
|
13
14
|
|
|
14
15
|
**Wire / casing: snake_case end to end.** The clients send and return snake_case **verbatim** (`created_at`, `group_ids`, `can_read`, `amount_cents`, `data_type`, `is_active`, …). There is **no case transform anywhere** — not on the client and not in the backend; the only casing boundary is Prisma `@map` (snake_case field → camelCase column). Author-defined record column values pass through verbatim. Every `list(...)` returns the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you.
|
|
@@ -23,6 +24,8 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
23
24
|
| **CORE** | `useUser()` | `{ id, email, displayName, photoUrl, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope. `photoUrl` is the user's own profile photo as an absolute URL, or `null`. |
|
|
24
25
|
| **CORE** | `useUserPhoto()` | `{ url, canEdit, upload, remove, uploading, error }` | `ctx.user` + the optional `ctx.account` — no scope. Changes the signed-in user's OWN profile photo. `canEdit` is false when nobody is signed in or the host cannot change it; `upload` / `remove` then reject. |
|
|
25
26
|
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute, openLink }` | `ctx.navigation` — no scope. `goTo(pageIdOrSlug, params?)` accepts a page UUID (a `pageRef` prop) **or its slug** (a row's page-key field) — both hosts resolve either (`openLink` for a link of unknown shape; a known external URL can also use the `Linking` primitive) |
|
|
27
|
+
| **CORE** | `useFilePreview(file)` | `{ uri }` | No scope. A temporary address an `<Image>` can show for a file the person picked but has not uploaded yet (a photo in a composer). Native picks use their own uri; on the web the SDK creates and releases an object URL, which widgets may not do themselves. `uri` is null for no file. |
|
|
28
|
+
| **CORE** | `usePageBadge(count)` | `{ supported }` | `ctx.navigation.setBadge` — no scope. Puts `count` on the menu entry or tab of the page the widget is on (a chat's unread messages). The host sums the page's widgets and keeps the last count after the widget leaves the screen, until it reports again; `0` clears this widget's share. `supported` is false where the host draws no page badges (the Builder canvas). |
|
|
26
29
|
| **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
|
|
27
30
|
| **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
|
|
28
31
|
| **CORE** | `useWidgetRoute(initial)` | `[state, setState]` | `ctx.widgetRoute` — no scope. Where YOUR WIDGET is, persisted by the host so it survives a reload and travels in a shared link (the `w_<instanceId>` query key on web, the screen's route params natively): the folder a browser has opened, a wizard step, a selected tab, a list's sort and search. `useState` semantics over an object — writes MERGE, `null` clears a key back to its `initial`, and `initial` is read once. Values are scalars or flat arrays of scalars, size- and length-capped; anything else is not stored. NOT history (Back still leaves the page, on both platforms). Degrades to component state on the Studio canvas. |
|
|
@@ -57,8 +60,9 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
57
60
|
| **DATASTORE** | `useTyping(tableId, recordId)` | `{ typingUserIds, setTyping, status }` — status `"connecting" \| "live" \| "unavailable"` | `records(table).typing` — `datastore.read:<table>`. Who else is typing in one record, over the same socket as `useDatastoreSubscription`. Call `setTyping(true)` on every keystroke (throttled to one signal per 3 s; `false` is sent after 5 s idle) and `setTyping(false)` on send, clear or blur. `typingUserIds` lists the OTHER users' ids; each drops out 6 s after their last signal. Needs a signed-in user who can read the record. Never throws: an anonymous visitor, a refused subscription or a host whose client lacks `typing` reads `{ typingUserIds: [], status: "unavailable" }`. Re-subscribes when either id changes. |
|
|
58
61
|
| **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
|
|
59
62
|
| **DATASTORE** | `useCanWrite(tableId, options)` (optional: options) | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
|
|
60
|
-
| **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url,
|
|
63
|
+
| **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, asset, loading, error, refetch }` | `ctx.assets.get` — no scope |
|
|
61
64
|
| **FILES** | `useAssetsByTag(tag, { type })` (optional: the whole argument, and `type` within it) | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
|
|
65
|
+
| **FILES** (`ctx.widget`) | `useBundledAsset(fileName)` | `string \| null` | `ctx.widget.assets` — no scope. The URL of an image or sound the widget package ships in its `assets/` directory, or `null` when it has no file with that name. See [Bundled images and sounds](#bundled-images-and-sounds). |
|
|
62
66
|
| **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
|
|
63
67
|
| **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset, update }` | `directory.users.*` — `users.read:*` plus the `users.read` (or `users.write`) capability, else `FORBIDDEN` (edits, incl. `sendPasswordReset()` and `update()`, also `users.write:*`; `remove()` also `users.delete:*`) |
|
|
64
68
|
| **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
|
|
@@ -67,6 +71,8 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
67
71
|
| **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: spaceType, folderId) | `{ openPdf, opening, canOpenPdf, exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.renderPdf` + `ctx.device.pdfViewer`, `ctx.filestore.files.exportPdf` — `files.write:*`. `openPdf(html, { fileName? })` renders the HTML to a PDF server-side and OPENS it (web: PDF tab; native: print / share sheet) without saving. `exportToPdf(html, { fileName?, folderId? })` renders it and saves it as a file (`application/pdf`). Same server-side renderer on web + native. |
|
|
68
72
|
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
|
|
69
73
|
| **NOTIFICATIONS** (`ctx.notifications`) | `useSendNotification()` | `{ send, sending, error }` | `ctx.notifications.send` — `notifications.send:appUser`. `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace; call from an event handler (never render); rejects with `NotificationError`. |
|
|
74
|
+
| **NOTIFICATIONS** (`ctx.notifications`) | `useRecordMutes(tableId)` | `{ isMuted, loading, error, refetch }` | `ctx.notifications.mutes` — no scope. Which records of one table the signed-in user has muted, in one request for a whole list; `isMuted(recordId)` turns false when a timed mute lapses, and a `useRecordMute` write shows at once. |
|
|
75
|
+
| **NOTIFICATIONS** (`ctx.notifications`) | `useRecordMute(tableId, recordId)` | `{ muted, mutedUntil, loading, error, mute, unmute, refetch }` | `ctx.notifications.mutes` — no scope. Mutes one record's notifications for the signed-in user, `mute(until)` with an ISO time, a `Date`, or `null` for until unmuted. Needs read access to the record (`RECORD_NOT_FOUND` otherwise). |
|
|
70
76
|
| **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider, purpose, pollIntervalMs })` (optional: every key, and the whole argument) | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |
|
|
71
77
|
|
|
72
78
|
All list calls return the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you. There is no `useWorkspace()` or `useLogger()` hook — read the theme via `useTheme()` and the locale via `useI18n()`; the host logger lives on `ctx.logger` (`{ debug, info, warn, error }`).
|
|
@@ -77,7 +83,80 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
77
83
|
|
|
78
84
|
## Status
|
|
79
85
|
|
|
80
|
-
`v0.
|
|
86
|
+
`v0.199.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**.
|
|
87
|
+
|
|
88
|
+
### What's new in 0.199.0 (contract 1.159.0)
|
|
89
|
+
|
|
90
|
+
**A widget package can bundle its own images and sounds — `assets/` + `useBundledAsset(fileName)`.** Files in a flat `assets/` directory ship with the package, and the new hook returns each one's URL on web and native alike:
|
|
91
|
+
|
|
92
|
+
```jsx
|
|
93
|
+
import { Image, useAudioPlayer, useBundledAsset } from "@colixsystems/widget-sdk";
|
|
94
|
+
|
|
95
|
+
function Welcome() {
|
|
96
|
+
const logo = useBundledAsset("logo.png");
|
|
97
|
+
const chime = useAudioPlayer(useBundledAsset("chime.mp3"));
|
|
98
|
+
return logo ? <Image source={{ uri: logo }} style={{ width: 96, height: 96 }} /> : null;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- **Allowed:** `png`, `jpg`, `jpeg`, `gif`, `webp`, `mp3`, `m4a` and `wav`; at most 50 files; the whole package at most 12 MB. The rules are published as `CONTRACT.bundledAssets` and `CONTRACT.packageMaxBytes`.
|
|
103
|
+
- **`ctx.widget.assets`** is the map the hook reads: bundled file name → absolute URL, `{}` when the package bundles nothing. A host that predates it publishes no map, and the hook returns `null`.
|
|
104
|
+
- **New linter rule `bundled-asset-missing` (error).** With the new `assetFiles` option, `lintSource` flags a `useBundledAsset("name")` literal naming a file the package does not ship. `appstudio-widget lint` passes the option itself and also validates every file in `assets/`.
|
|
105
|
+
- **New exports** `validateBundledAssets`, `sniffBundledAssetType` and `isValidBundledAssetName`, from the package root and from `@colixsystems/widget-sdk/bundled-assets` (ESM and CommonJS).
|
|
106
|
+
- **`appstudio-widget dev`** serves a widget directory's files at `/assets/<name>`, lists them at `/assets.json`, and reloads when one changes.
|
|
107
|
+
|
|
108
|
+
See [Bundled images and sounds](#bundled-images-and-sounds). Additive: a widget with no `assets/` directory is unaffected.
|
|
109
|
+
### What's new in 0.198.0 (contract 1.158.0)
|
|
110
|
+
|
|
111
|
+
**`manifest.translations` may declare up to 200 keys (was 100).** A messenger or a form builder outgrows 100 strings once it ships every label its users read. Widening: every manifest that validated before still does.
|
|
112
|
+
|
|
113
|
+
**`useDirectory({ ids })` resolves names for the people a widget already knows about.** Pass up to 100 user ids — message senders, members of a conversation — and get their `{ id, name, role }` rows without loading the whole roster; pair it with `q` for a server-side people search. Additive.
|
|
114
|
+
|
|
115
|
+
**Files can be attached to a datastore record and stay private to it.** `useFilestoreUpload({ spaceType: "record", recordId })` attaches the uploaded file to that record, and `useFilestoreFiles({ spaceType: "record", recordId, type })` lists its attachments newest first (`type: "media"` for images and videos, `"document"` for the rest). Exactly the people who may read the record may read its attachments, through the same permission check as the record itself. Uploading needs write on the record, only the uploader may delete an attachment, and removing the record removes its attachments. A record attachment's URL is issued to one viewer and lives about ten minutes, so re-resolve it (`useFilestoreFile(id).refetch()`) when an image fails to load. The new `useFilestoreDelete()` returns `{ remove, removing, error }`: `remove(fileId)` deletes a record attachment permanently (only its uploader may) and moves any other file to the trash. `useFilestoreCopy().copy(fileId, { spaceType: "record", recordId })` attaches a copy of a file to a record, with the same write check as uploading there. `useCamera().pick({ multiple: true })` lets the user choose up to 10 photos from the device library at once and resolves an array of assets; `pick()` alone is unchanged. `<Avatar seed={user.id} />` draws the initials on that person's own colour, the same one the Studio's member lists use, and `avatarColorFor(seed)` returns it.
|
|
116
|
+
|
|
117
|
+
**A `datastoreTemplate` can make inherited access read-only and stamp author columns on the server.** An `inheritAcl` RELATION column may add `inheritAclReadOnly: true`. Rows then inherit only READ access from the row they point at: members of a conversation read every message in it and may post into it, but change or delete only the messages they created (pair it with `grantCreatorPermissions`). This also holds down a chain, such as a reaction on a message in a conversation. A column may declare `defaultValue`, which is written when a create omits the column. Use `"@currentUser"` on a USER column, `"@now"` on a DATE or DATETIME column, or a literal of the column's own type. FILE, RELATION and USER_GROUP columns take no default, because ids belong to the installing workspace. `defaultLocked: true` on a `"@currentUser"` or `"@now"` default makes the server always stamp it. Any value a client sends for the column is ignored on create and on update, so a `Sender` or `CreatedBy` column is always the real author. `immutable: true` makes a column write-once: a create sets it from the value sent or the default, and any value sent for it on update is ignored, so a message's `Conversation` cannot be moved after it is posted. It is not valid on a MANY_TO_MANY relation, whose links live outside the row. Additive.
|
|
118
|
+
|
|
119
|
+
**A user can mute one record's notifications.** `useRecordMute(tableId, recordId)` returns `{ muted, mutedUntil, loading, error, mute, unmute, refetch }`. `mute(until)` takes an ISO time or a `Date`, or `null` (or nothing) to mute until `unmute()`. While a mute is active, a server action's `notifications.notifyRecordSubjects` on that record sends the user nothing: no inbox row, no push, no email. `mutedUntil` is the ISO time the mute ends, or `null` when it lasts until unmuted, and `muted` turns false by itself when that time passes. The user must be able to read the record, so a record they cannot read fails with `NotificationError { code: "RECORD_NOT_FOUND" }`, just like one that does not exist. A falsy id returns a stable not-muted result with no request. On a host without `ctx.notifications.mutes` (the Builder canvas preview) the hook reads not muted, and `mute()` / `unmute()` reject with `NOTIFICATIONS_UNAVAILABLE`. No scope is needed, because it only changes the user's own setting. To show mutes in a list, call `useRecordMutes(tableId)` once: `isMuted(recordId)` answers for every row from one request, and a mute made through `useRecordMute` on the same table shows at once. `NotificationError.code` now carries the server's code when it sends one (`INVALID_TITLE`, `RECORD_NOT_FOUND`, …), and a 401 reads `AUTH_REQUIRED`. Additive.
|
|
120
|
+
|
|
121
|
+
```jsx
|
|
122
|
+
import { useRecordMute, Pressable, Text } from "@colixsystems/widget-sdk";
|
|
123
|
+
|
|
124
|
+
function MuteToggle({ tableId, conversationId }) {
|
|
125
|
+
const { muted, mute, unmute } = useRecordMute(tableId, conversationId);
|
|
126
|
+
return (
|
|
127
|
+
<Pressable onPress={() => (muted ? unmute() : mute(null)).catch(() => {})}>
|
|
128
|
+
<Text>{muted ? "Unmute" : "Mute"}</Text>
|
|
129
|
+
</Pressable>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**A list can show every muted record at once.** `useRecordMutes(tableId)` returns `{ isMuted, loading, error, refetch }` from one request, so a conversation list marks its muted rows without a read per row. No scope. Additive.
|
|
135
|
+
|
|
136
|
+
**A widget can put a count on its page's menu entry.** `usePageBadge(count)` returns `{ supported }` and shows `count` on the menu entry or tab of the page the widget is on, on the web Player and in the exported app, through the new optional `ctx.navigation.setBadge`. The host adds up the counts of the page's widgets and keeps the last one after the widget leaves the screen; `0` clears it. No scope. Additive.
|
|
137
|
+
|
|
138
|
+
**A picked file can be shown before it is uploaded.** `useFilePreview(file)` returns `{ uri }` for an `<Image>`: a native pick's own `uri`, or an object URL the hook releases on the web. No scope. Additive.
|
|
139
|
+
|
|
140
|
+
**Videos play inline on both hosts.** `<Video source={url} />` plays a file with the platform's own controls and shows its first frame before playback: the browser's `<video>` on the web, expo-video's VideoView in the exported app. `contentFit` is `"contain"` (default) or `"cover"`, `controls` defaults to `true`, `autoPlay` starts it muted, and `onError()` fires when this host cannot play the file, so offer a download instead. A browser plays only the codecs it supports; H.264 MP4 plays everywhere.
|
|
141
|
+
|
|
142
|
+
```jsx
|
|
143
|
+
import { Video } from "@colixsystems/widget-sdk";
|
|
144
|
+
|
|
145
|
+
<Video
|
|
146
|
+
source={file.url}
|
|
147
|
+
contentFit="cover"
|
|
148
|
+
style={{ width: 240, height: 180, borderRadius: theme.radii.md, overflow: "hidden" }}
|
|
149
|
+
onError={() => setPlayable(false)}
|
|
150
|
+
/>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**A widget with pages of its own can answer Android's back button.** `useHardwareBack(handler)` runs `handler` on a back press while the widget's screen is in view; return `true` when it went back a step (closed a thread, left a settings page), `false` to let the app go back, and pass `null` while there is nothing to go back from. The exported app answers only for the screen in view, so a widget kept mounted on another tab never swallows the press. `supported` is `false` on the web Player, where browsers have no hardware back, so keep a visible back control too. No scope. Additive.
|
|
154
|
+
|
|
155
|
+
**The photo picker chooses several, and videos.** `useCamera().pick({ multiple: true })` resolves an array of up to 10 assets, and `pick({ videos: true })` offers videos beside photos. A video asset has a `video/*` `mimeType` and `durationMs` (`null` for a photo); on iOS it arrives as H.264 so other devices can play it. Additive.
|
|
156
|
+
|
|
157
|
+
**A person keeps one avatar everywhere.** `<Avatar seed={userId}>` and `avatarColorFor(seed)` give a person the monogram colour the Studio's member lists use, and `initialsFor` now gives a one-word name two letters ("Börje" → "BÖ"), so an `<Avatar>` of a single-name user changes from one letter to two.
|
|
158
|
+
|
|
159
|
+
**Fixes.** `<Overlay>` opens at once under React StrictMode on the web: react-native-web's `Modal` lost its page container to StrictMode's effect replay, so a sheet stayed invisible until an unrelated re-render; the web binding now uses its own modal layer with the same dialog role, Escape, focus trap and focus return. `useDatastoreMutation` returns one stable object while its methods are unchanged, so a widget can list it as a dependency. `NotificationError.code` carries the server's code when it sends one.
|
|
81
160
|
|
|
82
161
|
### What's new in 0.197.0 (contract 1.157.0)
|
|
83
162
|
|
|
@@ -2540,6 +2619,70 @@ native-only package while claiming web (or vice-versa) fails the
|
|
|
2540
2619
|
platform, or ship the split pair. So you can't accidentally publish a widget
|
|
2541
2620
|
that renders on one platform and blanks on the other.
|
|
2542
2621
|
|
|
2622
|
+
## Bundled images and sounds
|
|
2623
|
+
|
|
2624
|
+
A widget package can ship its own images and sounds, so a logo, an illustration or a notification chime travels with the widget instead of being uploaded to every workspace by hand. Put the files in an `assets/` directory at the package root and list it in `package.json`'s `files`:
|
|
2625
|
+
|
|
2626
|
+
```
|
|
2627
|
+
my-widget/
|
|
2628
|
+
├── package.json "files": ["widget.jsx", "appstudio.manifest.json", "assets"]
|
|
2629
|
+
├── manifest.js
|
|
2630
|
+
├── widget.jsx
|
|
2631
|
+
└── assets/
|
|
2632
|
+
├── logo.png
|
|
2633
|
+
└── chime.mp3
|
|
2634
|
+
```
|
|
2635
|
+
|
|
2636
|
+
The rules are published as `CONTRACT.bundledAssets` and `CONTRACT.packageMaxBytes`:
|
|
2637
|
+
|
|
2638
|
+
| Rule | Value |
|
|
2639
|
+
| ---- | ----- |
|
|
2640
|
+
| Images | `png`, `jpg`, `jpeg`, `gif`, `webp` |
|
|
2641
|
+
| Sounds | `mp3`, `m4a`, `wav` |
|
|
2642
|
+
| Layout | one flat `assets/` directory — no subdirectories |
|
|
2643
|
+
| File name | starts with a letter or digit; letters, digits, `.`, `-` and `_` only; at most 64 characters; unique ignoring case |
|
|
2644
|
+
| Files | at most 50 |
|
|
2645
|
+
| Package size | at most 12 MB for the whole package, bundled files included |
|
|
2646
|
+
|
|
2647
|
+
A file must really be the type its extension says: the content is checked, so a renamed `.svg` or `.bmp` is refused.
|
|
2648
|
+
|
|
2649
|
+
Read a file with `useBundledAsset(fileName)`. It returns an absolute URL on both the web Player and the native app, or `null` when the package has no file with that name:
|
|
2650
|
+
|
|
2651
|
+
```jsx
|
|
2652
|
+
import {
|
|
2653
|
+
Image,
|
|
2654
|
+
Pressable,
|
|
2655
|
+
Text,
|
|
2656
|
+
useAudioPlayer,
|
|
2657
|
+
useBundledAsset,
|
|
2658
|
+
} from "@colixsystems/widget-sdk";
|
|
2659
|
+
|
|
2660
|
+
export default function Welcome() {
|
|
2661
|
+
const logo = useBundledAsset("logo.png");
|
|
2662
|
+
const chime = useAudioPlayer(useBundledAsset("chime.mp3"));
|
|
2663
|
+
return (
|
|
2664
|
+
<Pressable onPress={() => chime.play()}>
|
|
2665
|
+
{logo ? <Image source={{ uri: logo }} style={{ width: 96, height: 96 }} /> : null}
|
|
2666
|
+
<Text>Tap to play</Text>
|
|
2667
|
+
</Pressable>
|
|
2668
|
+
);
|
|
2669
|
+
}
|
|
2670
|
+
```
|
|
2671
|
+
|
|
2672
|
+
Only an uploaded package can bundle files. A widget written in the Studio has none, so `useBundledAsset` returns `null` there; use `useAsset` / `useAssetsByTag` for files a workspace uploads itself. When a workspace installs the widget, its bundled files also appear in that workspace's assets.
|
|
2673
|
+
|
|
2674
|
+
`appstudio-widget lint` checks an `assets/` directory beside each file it lints. A file that may not be bundled is an error named by its code (`ASSET_NAME_INVALID`, `ASSET_TYPE_NOT_ALLOWED`, `ASSET_EMPTY`, `ASSET_CONTENT_MISMATCH`, `ASSET_NAME_DUPLICATE`, `ASSET_TOO_MANY`), and the linter rule **`bundled-asset-missing`** (error) flags a `useBundledAsset("name")` literal that names a file the directory does not contain. A name built at runtime is not checked. The same checks are available as functions:
|
|
2675
|
+
|
|
2676
|
+
```js
|
|
2677
|
+
import { lintSource } from "@colixsystems/widget-sdk/linter";
|
|
2678
|
+
import { validateBundledAssets } from "@colixsystems/widget-sdk/bundled-assets";
|
|
2679
|
+
|
|
2680
|
+
const { ok, errors, assets } = validateBundledAssets([{ name: "logo.png", bytes }]);
|
|
2681
|
+
const report = lintSource(source, { assetFiles: assets.map((a) => a.name) });
|
|
2682
|
+
```
|
|
2683
|
+
|
|
2684
|
+
`validateBundledAssets(files)` returns `{ ok, errors: [{ code, name, message }], assets: [{ name, kind, mimeType, sizeBytes }] }`. `sniffBundledAssetType(bytes)` identifies one file from its leading bytes and `isValidBundledAssetName(name)` checks one name. Omit `assetFiles` and `lintSource` skips `bundled-asset-missing`.
|
|
2685
|
+
|
|
2543
2686
|
## Linter
|
|
2544
2687
|
|
|
2545
2688
|
```sh
|
|
@@ -2589,7 +2732,9 @@ and serves it over `http://127.0.0.1:4400`:
|
|
|
2589
2732
|
| ----- | ------- |
|
|
2590
2733
|
| `GET /widget.mjs` | the transpiled entry — an ES module the Studio loads exactly like a published `widget.mjs` |
|
|
2591
2734
|
| `GET /manifest.json` | the manifest (from `--manifest`, else a sibling `manifest.json`); only consulted for a bare-component bundle |
|
|
2592
|
-
| `GET /
|
|
2735
|
+
| `GET /assets/<name>` | a bundled image or sound from the widget directory's `assets/` (directory mode) |
|
|
2736
|
+
| `GET /assets.json` | `{ assets }` — the names of the valid bundled files, sorted |
|
|
2737
|
+
| `GET /__dev/events` | Server-Sent-Events stream that emits `reload` on every source change, and on every change under `assets/` |
|
|
2593
2738
|
| `GET /__dev/health` | `{ ok, manifestId }` |
|
|
2594
2739
|
|
|
2595
2740
|
Then, in the Studio Builder (dev mode only), open the **Dev widgets** panel in
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// sc-10268 — the ONE person tint, shared by the Studio's member lists and the
|
|
2
|
+
// SDK `<Avatar seed>`, so a person seeded the same way is one colour everywhere.
|
|
3
|
+
|
|
4
|
+
// Brand-neutral accents, each dark enough for white initials.
|
|
5
|
+
const AVATAR_COLORS = Object.freeze([
|
|
6
|
+
"#0f766e",
|
|
7
|
+
"#3f5bd6",
|
|
8
|
+
"#b0562a",
|
|
9
|
+
"#7a4bcc",
|
|
10
|
+
"#b83b5e",
|
|
11
|
+
"#2f7d4f",
|
|
12
|
+
"#9333ea",
|
|
13
|
+
"#0369a1",
|
|
14
|
+
"#c2410c",
|
|
15
|
+
"#0e7490",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A stable colour for a seed (a user id, an email), or null for no seed.
|
|
20
|
+
*
|
|
21
|
+
* FNV-1a plus a murmur-style avalanche: a plain `hash * 31 % N` keys the bucket
|
|
22
|
+
* off the low bits, which barely mix, so most seeds collide on one colour.
|
|
23
|
+
*/
|
|
24
|
+
export function avatarColorFor(seed) {
|
|
25
|
+
if (typeof seed !== "string" || !seed) return null;
|
|
26
|
+
let h = 2166136261;
|
|
27
|
+
for (let i = 0; i < seed.length; i += 1) {
|
|
28
|
+
h ^= seed.charCodeAt(i);
|
|
29
|
+
h = Math.imul(h, 16777619);
|
|
30
|
+
}
|
|
31
|
+
h ^= h >>> 13;
|
|
32
|
+
h = Math.imul(h, 0x5bd1e995);
|
|
33
|
+
h ^= h >>> 15;
|
|
34
|
+
return AVATAR_COLORS[(h >>> 0) % AVATAR_COLORS.length];
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Reads a package's assets/ directory from disk for the Node-side tools (the
|
|
2
|
+
// CLI, the dev server, the first-party packer). The rules themselves live in
|
|
3
|
+
// bundled-assets.js, which stays filesystem-free.
|
|
4
|
+
|
|
5
|
+
import { lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
BUNDLED_ASSET_DIRECTORY,
|
|
9
|
+
validateBundledAssets,
|
|
10
|
+
} from "./bundled-assets.js";
|
|
11
|
+
|
|
12
|
+
const NO_BYTES = new Uint8Array(0);
|
|
13
|
+
|
|
14
|
+
// npm pack never ships these, so they are not part of the package.
|
|
15
|
+
function _isOsMetadata(name) {
|
|
16
|
+
return name === ".DS_Store" || name.startsWith("._");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function _isRealDirectory(path) {
|
|
20
|
+
if (!path) return false;
|
|
21
|
+
try {
|
|
22
|
+
return lstatSync(path).isDirectory();
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function _readEntry(assetsDir, entry) {
|
|
29
|
+
const absPath = join(assetsDir, entry.name);
|
|
30
|
+
// A trailing slash makes the validator refuse the subdirectory by name.
|
|
31
|
+
if (entry.isDirectory()) return { name: `${entry.name}/`, absPath, bytes: NO_BYTES };
|
|
32
|
+
return { name: entry.name, absPath, bytes: readFileSync(absPath) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Path of the assets/ directory that sits beside a widget's source. */
|
|
36
|
+
export function bundledAssetDirectoryOf(ownerDir) {
|
|
37
|
+
return join(ownerDir, BUNDLED_ASSET_DIRECTORY);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read and validate one assets/ directory (non-recursive). Links are never
|
|
42
|
+
* followed: a linked directory reads as absent and a linked file is skipped.
|
|
43
|
+
*
|
|
44
|
+
* Returns the `validateBundledAssets` result plus `files`
|
|
45
|
+
* (`[{ name, absPath, bytes }]`) and `fileNames` (the regular files present,
|
|
46
|
+
* valid or not). A missing directory, or no path, is an empty, valid result.
|
|
47
|
+
*/
|
|
48
|
+
export function loadBundledAssets(assetsDir) {
|
|
49
|
+
const files = _isRealDirectory(assetsDir)
|
|
50
|
+
? readdirSync(assetsDir, { withFileTypes: true })
|
|
51
|
+
.filter((entry) => entry.isFile() || entry.isDirectory())
|
|
52
|
+
.filter((entry) => !_isOsMetadata(entry.name))
|
|
53
|
+
.map((entry) => _readEntry(assetsDir, entry))
|
|
54
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
55
|
+
: [];
|
|
56
|
+
const fileNames = files
|
|
57
|
+
.filter((file) => !file.name.endsWith("/"))
|
|
58
|
+
.map((file) => file.name);
|
|
59
|
+
return { files, fileNames, ...validateBundledAssets(files) };
|
|
60
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// The files a widget package ships in its assets/ directory: which names and
|
|
2
|
+
// types are allowed. Pure and filesystem-free, so the CLI, the dev server, the
|
|
3
|
+
// first-party packer and the backend all apply one rule set.
|
|
4
|
+
// CommonJS twin of bundled-assets.js for the backend. Keep both in lockstep.
|
|
5
|
+
|
|
6
|
+
const { CONTRACT } = require("./contract.cjs");
|
|
7
|
+
|
|
8
|
+
const RULES = CONTRACT.bundledAssets;
|
|
9
|
+
const BUNDLED_ASSET_DIRECTORY = RULES.directory;
|
|
10
|
+
const ALLOWED_EXTENSIONS = Object.values(RULES.types).flatMap(
|
|
11
|
+
(type) => type.extensions,
|
|
12
|
+
);
|
|
13
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
14
|
+
|
|
15
|
+
const PNG = { kind: "image", mimeType: "image/png", extensions: ["png"] };
|
|
16
|
+
const JPEG = { kind: "image", mimeType: "image/jpeg", extensions: ["jpg", "jpeg"] };
|
|
17
|
+
const GIF = { kind: "image", mimeType: "image/gif", extensions: ["gif"] };
|
|
18
|
+
const WEBP = { kind: "image", mimeType: "image/webp", extensions: ["webp"] };
|
|
19
|
+
const WAV = { kind: "audio", mimeType: "audio/wav", extensions: ["wav"] };
|
|
20
|
+
const MP3 = { kind: "audio", mimeType: "audio/mpeg", extensions: ["mp3"] };
|
|
21
|
+
const M4A = { kind: "audio", mimeType: "audio/mp4", extensions: ["m4a"] };
|
|
22
|
+
|
|
23
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
24
|
+
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
|
|
25
|
+
const M4A_BRANDS = ["M4A ", "M4B ", "mp42", "isom", "mp41"];
|
|
26
|
+
|
|
27
|
+
function _hasBytes(bytes, signature, offset) {
|
|
28
|
+
if (bytes.length < offset + signature.length) return false;
|
|
29
|
+
return signature.every((byte, i) => bytes[offset + i] === byte);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _hasText(bytes, text, offset) {
|
|
33
|
+
return _hasBytes(bytes, Array.from(text, (ch) => ch.charCodeAt(0)), offset);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// kbps by bitrate index; index 0 (free format) and 15 are never accepted.
|
|
37
|
+
const MPEG_BITRATES = {
|
|
38
|
+
v1Layer1: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448],
|
|
39
|
+
v1Layer2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384],
|
|
40
|
+
v1Layer3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
|
|
41
|
+
v2Layer1: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256],
|
|
42
|
+
v2Layer2or3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
|
|
43
|
+
};
|
|
44
|
+
// Hz by the header's version bits (3 = MPEG-1, 2 = MPEG-2, 0 = MPEG-2.5).
|
|
45
|
+
const MPEG_SAMPLE_RATES = {
|
|
46
|
+
3: [44100, 48000, 32000],
|
|
47
|
+
2: [22050, 24000, 16000],
|
|
48
|
+
0: [11025, 12000, 8000],
|
|
49
|
+
};
|
|
50
|
+
const MPEG_LAYER_1 = 3;
|
|
51
|
+
const MPEG_LAYER_3 = 1;
|
|
52
|
+
|
|
53
|
+
function _mpegBitrateTable(isVersion1, layer) {
|
|
54
|
+
if (layer === MPEG_LAYER_1) return isVersion1 ? MPEG_BITRATES.v1Layer1 : MPEG_BITRATES.v2Layer1;
|
|
55
|
+
if (!isVersion1) return MPEG_BITRATES.v2Layer2or3;
|
|
56
|
+
return layer === MPEG_LAYER_3 ? MPEG_BITRATES.v1Layer3 : MPEG_BITRATES.v1Layer2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Byte length of the MPEG audio frame whose header starts at `offset`, or 0. */
|
|
60
|
+
function _mpegFrameLength(bytes, offset) {
|
|
61
|
+
if (bytes.length < offset + 4) return 0;
|
|
62
|
+
if (bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) return 0;
|
|
63
|
+
const version = (bytes[offset + 1] >> 3) & 3;
|
|
64
|
+
const layer = (bytes[offset + 1] >> 1) & 3;
|
|
65
|
+
const bitrateIndex = bytes[offset + 2] >> 4;
|
|
66
|
+
const sampleRateIndex = (bytes[offset + 2] >> 2) & 3;
|
|
67
|
+
const padding = (bytes[offset + 2] >> 1) & 1;
|
|
68
|
+
if (version === 1 || layer === 0 || sampleRateIndex === 3) return 0;
|
|
69
|
+
if (bitrateIndex === 0 || bitrateIndex === 15) return 0;
|
|
70
|
+
const isVersion1 = version === 3;
|
|
71
|
+
const bitrate = _mpegBitrateTable(isVersion1, layer)[bitrateIndex] * 1000;
|
|
72
|
+
const sampleRate = MPEG_SAMPLE_RATES[version][sampleRateIndex];
|
|
73
|
+
if (layer === MPEG_LAYER_1) return (Math.floor((12 * bitrate) / sampleRate) + padding) * 4;
|
|
74
|
+
const samplesPerByte = layer === MPEG_LAYER_3 && !isVersion1 ? 72 : 144;
|
|
75
|
+
return Math.floor((samplesPerByte * bitrate) / sampleRate) + padding;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A two-byte sync alone matches a UTF-16 BOM, so a second frame must follow.
|
|
79
|
+
function _isMpegAudioAt(bytes, offset) {
|
|
80
|
+
const length = _mpegFrameLength(bytes, offset);
|
|
81
|
+
if (length === 0) return false;
|
|
82
|
+
const next = offset + length;
|
|
83
|
+
return next === bytes.length || _mpegFrameLength(bytes, next) > 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ID3_HEADER_BYTES = 10;
|
|
87
|
+
|
|
88
|
+
function _isMp3(bytes) {
|
|
89
|
+
if (!_hasText(bytes, "ID3", 0)) return _isMpegAudioAt(bytes, 0);
|
|
90
|
+
if (bytes.length < ID3_HEADER_BYTES || bytes[3] === 0xff || bytes[4] === 0xff) return false;
|
|
91
|
+
const size = [bytes[6], bytes[7], bytes[8], bytes[9]];
|
|
92
|
+
if (size.some((byte) => byte >= 0x80)) return false;
|
|
93
|
+
const tagBytes = (size[0] << 21) | (size[1] << 14) | (size[2] << 7) | size[3];
|
|
94
|
+
const footerBytes = bytes[5] & 0x10 ? ID3_HEADER_BYTES : 0;
|
|
95
|
+
return _isMpegAudioAt(bytes, ID3_HEADER_BYTES + tagBytes + footerBytes);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _describe(type) {
|
|
99
|
+
return { kind: type.kind, mimeType: type.mimeType, extensions: [...type.extensions] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _sniff(bytes) {
|
|
103
|
+
if (_hasBytes(bytes, PNG_SIGNATURE, 0)) return PNG;
|
|
104
|
+
if (_hasBytes(bytes, JPEG_SIGNATURE, 0)) return JPEG;
|
|
105
|
+
if (_hasText(bytes, "GIF87a", 0) || _hasText(bytes, "GIF89a", 0)) return GIF;
|
|
106
|
+
if (_hasText(bytes, "RIFF", 0)) {
|
|
107
|
+
if (_hasText(bytes, "WEBP", 8)) return WEBP;
|
|
108
|
+
if (_hasText(bytes, "WAVE", 8)) return WAV;
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (_hasText(bytes, "ftyp", 4)) {
|
|
112
|
+
return M4A_BRANDS.some((brand) => _hasText(bytes, brand, 8)) ? M4A : null;
|
|
113
|
+
}
|
|
114
|
+
if (_isMp3(bytes)) return MP3;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Identify a bundled file from its leading bytes alone.
|
|
120
|
+
* Returns `{ kind: "image" | "audio", mimeType, extensions }`, or `null` when
|
|
121
|
+
* the bytes are not one of the bundled types.
|
|
122
|
+
*/
|
|
123
|
+
function sniffBundledAssetType(bytes) {
|
|
124
|
+
if (!bytes || typeof bytes.length !== "number") return null;
|
|
125
|
+
const type = _sniff(bytes);
|
|
126
|
+
return type ? _describe(type) : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _extensionOf(name) {
|
|
130
|
+
const dot = name.lastIndexOf(".");
|
|
131
|
+
return dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _isWellFormedName(name) {
|
|
135
|
+
return (
|
|
136
|
+
typeof name === "string" &&
|
|
137
|
+
name.length <= RULES.maxNameLength &&
|
|
138
|
+
NAME_RE.test(name) &&
|
|
139
|
+
!name.includes("..")
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True when `name` may be used as a file name inside a package's assets/. */
|
|
144
|
+
function isValidBundledAssetName(name) {
|
|
145
|
+
return _isWellFormedName(name) && ALLOWED_EXTENSIONS.includes(_extensionOf(name));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _extensionList() {
|
|
149
|
+
const last = ALLOWED_EXTENSIONS[ALLOWED_EXTENSIONS.length - 1];
|
|
150
|
+
return `${ALLOWED_EXTENSIONS.slice(0, -1).join(", ")} and ${last}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _error(code, name, rule) {
|
|
154
|
+
const where = `${BUNDLED_ASSET_DIRECTORY}/${name === null ? "" : name}`;
|
|
155
|
+
return { code, name, message: `${where}: ${rule}` };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function _fileError(file, seenNames) {
|
|
159
|
+
const { name, bytes } = file;
|
|
160
|
+
if (!_isWellFormedName(name)) {
|
|
161
|
+
return _error(
|
|
162
|
+
"ASSET_NAME_INVALID",
|
|
163
|
+
typeof name === "string" ? name : String(name),
|
|
164
|
+
"a bundled file name starts with a letter or digit, uses only letters, " +
|
|
165
|
+
`digits, dots, hyphens and underscores, and is at most ${RULES.maxNameLength} ` +
|
|
166
|
+
"characters long. Subdirectories are not allowed.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const extension = _extensionOf(name);
|
|
170
|
+
if (!ALLOWED_EXTENSIONS.includes(extension)) {
|
|
171
|
+
return _error(
|
|
172
|
+
"ASSET_TYPE_NOT_ALLOWED",
|
|
173
|
+
name,
|
|
174
|
+
`only ${_extensionList()} files can be bundled.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (seenNames.has(name.toLowerCase())) {
|
|
178
|
+
return _error(
|
|
179
|
+
"ASSET_NAME_DUPLICATE",
|
|
180
|
+
name,
|
|
181
|
+
"another bundled file has the same name when upper and lower case are ignored.",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
seenNames.add(name.toLowerCase());
|
|
185
|
+
if (!bytes || !bytes.length) {
|
|
186
|
+
return _error("ASSET_EMPTY", name, "the file is empty.");
|
|
187
|
+
}
|
|
188
|
+
const sniffed = _sniff(bytes);
|
|
189
|
+
if (!sniffed || !sniffed.extensions.includes(extension)) {
|
|
190
|
+
return _error(
|
|
191
|
+
"ASSET_CONTENT_MISMATCH",
|
|
192
|
+
name,
|
|
193
|
+
`the file's content is not a valid .${extension} file.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Validate the files of a package's assets/ directory.
|
|
201
|
+
* `files` is `[{ name, bytes }]`. Returns `{ ok, errors, assets }` where each
|
|
202
|
+
* error is `{ code, name, message }` and each accepted asset is
|
|
203
|
+
* `{ name, kind, mimeType, sizeBytes }`, sorted by name.
|
|
204
|
+
* Error codes: ASSET_NAME_INVALID, ASSET_TYPE_NOT_ALLOWED, ASSET_EMPTY,
|
|
205
|
+
* ASSET_CONTENT_MISMATCH, ASSET_NAME_DUPLICATE, ASSET_TOO_MANY.
|
|
206
|
+
*/
|
|
207
|
+
function validateBundledAssets(files) {
|
|
208
|
+
const list = Array.isArray(files) ? files : [];
|
|
209
|
+
const errors = [];
|
|
210
|
+
const assets = [];
|
|
211
|
+
const seenNames = new Set();
|
|
212
|
+
if (list.length > RULES.maxFiles) {
|
|
213
|
+
errors.push(
|
|
214
|
+
_error(
|
|
215
|
+
"ASSET_TOO_MANY",
|
|
216
|
+
null,
|
|
217
|
+
`a package can bundle at most ${RULES.maxFiles} files; this one has ${list.length}.`,
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
for (const file of list) {
|
|
222
|
+
const error = _fileError(file || {}, seenNames);
|
|
223
|
+
if (error) {
|
|
224
|
+
errors.push(error);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const { kind, mimeType } = _sniff(file.bytes);
|
|
228
|
+
assets.push({ name: file.name, kind, mimeType, sizeBytes: file.bytes.length });
|
|
229
|
+
}
|
|
230
|
+
assets.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
231
|
+
return { ok: errors.length === 0, errors, assets };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
BUNDLED_ASSET_DIRECTORY,
|
|
236
|
+
isValidBundledAssetName,
|
|
237
|
+
sniffBundledAssetType,
|
|
238
|
+
validateBundledAssets,
|
|
239
|
+
};
|