@calmlens/sdk 0.16.3 → 0.18.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.
Files changed (114) hide show
  1. package/README.md +425 -181
  2. package/bin/calmlens.js +16016 -0
  3. package/bin/package.json +1 -0
  4. package/cjs/BrowserUpload-BT091eOB.d.ts +11 -0
  5. package/cjs/BrowserUpload-CQwXEGFv.js +10 -0
  6. package/cjs/CalmLensAppClient-CHQGu-Do.js +89 -0
  7. package/cjs/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
  8. package/cjs/CalmLensConnection-B8Bu2j3y.js +659 -0
  9. package/cjs/CalmLensServerClient-C5o2A8MW.js +645 -0
  10. package/cjs/CalmLensServerClient-JtP687a3.d.ts +235 -0
  11. package/cjs/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
  12. package/cjs/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
  13. package/cjs/CryptoUtils-B_x8lMIx-YBW1-bvL.js +42 -0
  14. package/cjs/FileIcons-Ob003MdS.js +175 -0
  15. package/cjs/Jurisdiction-FusEXb4U-zVs21tDv.js +91 -0
  16. package/cjs/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
  17. package/cjs/ModerationContextLimits-DelWfmiJ.js +1311 -0
  18. package/cjs/ReactUpload-CAyCsyG6.d.ts +17 -0
  19. package/cjs/ReactUpload-Cfg5JYUq.js +54 -0
  20. package/cjs/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
  21. package/cjs/TestTriggers-DTc_iMJF-BjMPq4_3.js +1327 -0
  22. package/cjs/UploadController-BGO1eyzb.js +618 -0
  23. package/{esm/index-C9Xw1aid.d.ts → cjs/WebhookPayload-2aL31v0J.d.ts} +131 -155
  24. package/cjs/app.d.ts +7 -44
  25. package/cjs/app.js +11 -89
  26. package/cjs/elements.d.ts +88 -0
  27. package/cjs/elements.js +555 -0
  28. package/cjs/file-icons.d.ts +67 -0
  29. package/cjs/file-icons.js +10 -0
  30. package/cjs/index-CI4BzN4W.d.ts +252 -0
  31. package/cjs/index.d.ts +6 -4
  32. package/cjs/index.js +9 -4
  33. package/cjs/react-native.d.ts +56 -0
  34. package/cjs/react-native.js +495 -0
  35. package/cjs/react.d.ts +30 -0
  36. package/cjs/react.js +370 -0
  37. package/cjs/server.d.ts +12 -191
  38. package/cjs/server.js +18 -378
  39. package/cjs/test-inputs.d.ts +2 -2
  40. package/cjs/test-inputs.js +9 -264
  41. package/cjs/testing.d.ts +285 -190
  42. package/cjs/testing.js +2393 -1203
  43. package/cjs/ui-CC1GOoMg.js +583 -0
  44. package/cjs/ui-wIiMfqMB.d.ts +526 -0
  45. package/cjs/ui.d.ts +2 -0
  46. package/cjs/ui.js +27 -0
  47. package/cjs/vue.d.ts +490 -0
  48. package/cjs/vue.js +419 -0
  49. package/esm/BrowserUpload-BT091eOB.d.ts +11 -0
  50. package/esm/BrowserUpload-D2xBWqhm.js +5 -0
  51. package/esm/CalmLensAppClient-CV_K26Ce.d.ts +40 -0
  52. package/esm/CalmLensAppClient-DuFas_dW.js +84 -0
  53. package/esm/CalmLensConnection-MxwwLXdl.js +582 -0
  54. package/esm/CalmLensServerClient-D80_mKpW.js +586 -0
  55. package/esm/CalmLensServerClient-JtP687a3.d.ts +235 -0
  56. package/esm/CalmLensTypes-9Vt16pc7.d.ts +40400 -0
  57. package/esm/Classification-94kSYWZw-CMnQKW13.d.ts +5873 -0
  58. package/esm/CryptoUtils-B_x8lMIx-DhuWGzqn.js +25 -0
  59. package/esm/FileIcons-M6dzjFxd.js +128 -0
  60. package/esm/Jurisdiction-FusEXb4U-1ov6m5yE.js +46 -0
  61. package/esm/ModerationContext-9lGm-Mna-CcmbRnOy.d.ts +67 -0
  62. package/esm/ModerationContextLimits-BaX2QnU8.js +1114 -0
  63. package/esm/ReactUpload-CAyCsyG6.d.ts +17 -0
  64. package/esm/ReactUpload-CL2XC8WS.js +37 -0
  65. package/esm/TestTriggers-By07LR31-CmK0yTyR.d.ts +271 -0
  66. package/esm/TestTriggers-DTc_iMJF-HfB533Gx.js +1267 -0
  67. package/esm/UploadController-qjoYGCwx.js +589 -0
  68. package/{cjs/index-C9Xw1aid.d.ts → esm/WebhookPayload-2aL31v0J.d.ts} +131 -155
  69. package/esm/app.d.ts +7 -44
  70. package/esm/app.js +3 -86
  71. package/esm/elements.d.ts +88 -0
  72. package/esm/elements.js +528 -0
  73. package/esm/file-icons.d.ts +67 -0
  74. package/esm/file-icons.js +1 -0
  75. package/esm/index-CI4BzN4W.d.ts +252 -0
  76. package/esm/index.d.ts +6 -4
  77. package/esm/index.js +1 -1
  78. package/esm/react-native.d.ts +56 -0
  79. package/esm/react-native.js +467 -0
  80. package/esm/react.d.ts +30 -0
  81. package/esm/react.js +343 -0
  82. package/esm/server.d.ts +12 -191
  83. package/esm/server.js +3 -369
  84. package/esm/styles.css +147 -0
  85. package/esm/svelte/CalmLensMedia.svelte +134 -0
  86. package/esm/svelte/CalmLensUploader.svelte +175 -0
  87. package/esm/svelte/Icon.svelte +7 -0
  88. package/esm/svelte/index.d.ts +37 -0
  89. package/esm/svelte/index.js +6 -0
  90. package/esm/test-inputs.d.ts +2 -2
  91. package/esm/test-inputs.js +1 -256
  92. package/esm/testing.d.ts +285 -190
  93. package/esm/testing.js +2329 -1122
  94. package/esm/ui-CCY3yXFn.js +458 -0
  95. package/esm/ui-wIiMfqMB.d.ts +526 -0
  96. package/esm/ui.d.ts +2 -0
  97. package/esm/ui.js +3 -0
  98. package/esm/vue.d.ts +490 -0
  99. package/esm/vue.js +392 -0
  100. package/package.json +118 -13
  101. package/cjs/CalmLensConnection-BNUHp_AQ.js +0 -234
  102. package/cjs/CalmLensUtils-o171ownK.js +0 -699
  103. package/cjs/CalmLensVersion-DwhgxQK5.js +0 -480
  104. package/cjs/CalmLensWebhooks-BKrq30Yp.js +0 -201
  105. package/cjs/Classification-BNhgwGEm-DmA9VEuE.d.ts +0 -4806
  106. package/cjs/TestTriggers-CRKtulyp-vrzEq2wm.d.ts +0 -44
  107. package/cjs/WebhookPayload-CJoIkGBD.d.ts +0 -11753
  108. package/esm/CalmLensConnection-XTqmlwzG.js +0 -193
  109. package/esm/CalmLensUtils-DsF8Aa_x.js +0 -574
  110. package/esm/CalmLensVersion-DcifRKsx.js +0 -415
  111. package/esm/CalmLensWebhooks-BvGyk5d3.js +0 -148
  112. package/esm/Classification-BNhgwGEm-DmA9VEuE.d.ts +0 -4806
  113. package/esm/TestTriggers-CRKtulyp-vrzEq2wm.d.ts +0 -44
  114. package/esm/WebhookPayload-CJoIkGBD.d.ts +0 -11753
package/esm/server.js CHANGED
@@ -1,370 +1,4 @@
1
- import { a as CALMLENS_ERROR_CODES, c as invalidArgument, i as isReactNativeRuntime, l as invalidConfig, o as CalmLensError, r as isBrowserRuntime, s as SDK_ERROR_CODES, t as SDK_VERSION } from "./CalmLensVersion-DcifRKsx.js";
1
+ import { D as CALMLENS_ERROR_CODES, O as CalmLensError, c as DEFAULT_CDN_BASE_URL, i as SDK_VERSION, n as MODERATION_CONTEXT_USED_ITEMS, r as selectModerationContextItems, t as MODERATION_CONTEXT_USED_CHARACTERS, u as getAssetUrl } from "./ModerationContextLimits-BaX2QnU8.js";
2
2
  import "./index.js";
3
- import { a as parseWebhookEvent, i as WEBHOOK_TIMESTAMP_HEADER, l as stringifyJsonCanonical, n as WEBHOOK_SIGNATURE_HEADER, o as verifyWebhookSignature, r as WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, s as signUploadTokenWithApiKey, t as WEBHOOK_DELIVERY_HEADER } from "./CalmLensWebhooks-BvGyk5d3.js";
4
- import { f as sleep, l as validateRequestOptions, n as encodePathSegment, o as toTextAssetOptions, r as pollForDecision, s as transformSelector, t as appendCdnSelector, u as validateServerClientOptions } from "./CalmLensUtils-DsF8Aa_x.js";
5
- import { a as newIdempotencyKey, i as invalidPayload, n as expectAsset, o as prepareSubmitAssetOptions, s as resolveConnectionOptions, t as CalmLensConnection } from "./CalmLensConnection-XTqmlwzG.js";
6
- const getEnsureLensCacheKey = (options) => stringifyJsonCanonical({
7
- identifier: options.identifier,
8
- name: options.name,
9
- description: options.description ?? null,
10
- type: options.type,
11
- active: options.active,
12
- transforms: options.transforms ?? null,
13
- version: options.version
14
- });
15
- const requireId = (value, name) => encodePathSegment(value, name);
16
- const inspectSymbol = Symbol.for("nodejs.util.inspect.custom");
17
- /**
18
- * The CalmLens API client for your server, authenticated with a project API key. Create one per
19
- * project and reuse it; it holds no connections. Every method takes one options object and
20
- * throws `CalmLensError` on failure. Browsers and apps upload with `CalmLensAppClient` instead.
21
- */
22
- var CalmLensServerClient = class CalmLensServerClient {
23
- #apiKey;
24
- #settings;
25
- #lenses;
26
- #connection;
27
- /**
28
- * Verifies that a webhook delivery came from CalmLens: checks the `v1=` HMAC-SHA256 in
29
- * `x-calmlens-signature` over `"<x-calmlens-timestamp>.<raw body>"` and rejects timestamps
30
- * more than `toleranceSeconds` (default 300) from now, which stops replayed deliveries.
31
- * Also exported as `verifyWebhookSignature`; `parseWebhookEvent` verifies and parses.
32
- *
33
- * @example
34
- * ```ts
35
- * const payload = await request.text();
36
- * const isValid = await CalmLensServerClient.verifyWebhookSignature({
37
- * payload,
38
- * headers: request.headers,
39
- * secret: process.env.CALMLENS_WEBHOOK_SECRET!,
40
- * });
41
- * ```
42
- */
43
- static async verifyWebhookSignature(options) {
44
- return verifyWebhookSignature(options);
45
- }
46
- constructor(options, lenses) {
47
- validateServerClientOptions(options);
48
- if ((isBrowserRuntime() || isReactNativeRuntime()) && !options.dangerouslyAllowBrowser) throw invalidConfig("CalmLensServerClient is running in a browser or app, where anyone could read the API key. Call CalmLens from your server, upload from the browser or app with CalmLensAppClient, or set dangerouslyAllowBrowser: true for trusted internal tools.");
49
- const { apiKey, ...rest } = options;
50
- this.#apiKey = apiKey;
51
- this.#settings = {
52
- ...resolveConnectionOptions(rest),
53
- cdnBaseUrl: (rest.cdnBaseUrl ?? `https://cdn.calmlens.com/v1`).replace(/\/+$/, "")
54
- };
55
- this.#lenses = lenses ?? {
56
- ensuredLenses: /* @__PURE__ */ new Map(),
57
- pendingEnsureLenses: /* @__PURE__ */ new Map()
58
- };
59
- this.#connection = new CalmLensConnection(this.#settings, apiKey);
60
- }
61
- /**
62
- * A client with different cancellation, deadlines or retries for the calls made through it,
63
- * sharing this client's credentials and lens cache. This client is unchanged.
64
- */
65
- withRequestOptions(options) {
66
- validateRequestOptions(options);
67
- return new CalmLensServerClient({
68
- ...this.#settings,
69
- ...options,
70
- apiKey: this.#apiKey
71
- }, this.#lenses);
72
- }
73
- /** Keeps the API key out of `console.log`, `util.inspect` and `JSON.stringify`. */
74
- [inspectSymbol]() {
75
- const { projectId, baseUrl } = this.#settings;
76
- return `CalmLensServerClient { projectId: ${JSON.stringify(projectId)}, baseUrl: ${JSON.stringify(baseUrl)} }`;
77
- }
78
- toJSON() {
79
- const { projectId, baseUrl, cdnBaseUrl } = this.#settings;
80
- return {
81
- projectId,
82
- baseUrl,
83
- cdnBaseUrl
84
- };
85
- }
86
- get projectPath() {
87
- return `/projects/${encodeURIComponent(this.#settings.projectId)}`;
88
- }
89
- request(method, path, options = {}) {
90
- return this.#connection.json(method, path, options);
91
- }
92
- async submitAsset(options, queue) {
93
- const body = prepareSubmitAssetOptions(options);
94
- const uploadsPath = `${this.projectPath}/assets`;
95
- if ("content" in body || "part" in body) return expectAsset(await this.#connection.submitRawUpload(uploadsPath, body, queue));
96
- const { idempotencyKey, ...fields } = body;
97
- const data = await this.#connection.json("POST", uploadsPath, {
98
- query: { queue },
99
- body: fields,
100
- idempotencyKey: idempotencyKey ?? newIdempotencyKey()
101
- });
102
- return expectAsset(data);
103
- }
104
- get cdn() {
105
- return {
106
- /**
107
- * Builds a CDN URL for asset content or a preview, without a request. Private assets
108
- * need a signed URL (`assets.createSignedUrl`) or an authenticated request.
109
- */
110
- getAssetUrl: ({ assetIdentifier, ...options }) => {
111
- const resource = options.resource ?? "content";
112
- const { projectId } = this.#settings;
113
- const url = `${this.#settings.cdnBaseUrl}/assets/${encodeURIComponent(projectId)}/${requireId(assetIdentifier, "Asset identifier")}/${resource}`;
114
- return appendCdnSelector(url, options);
115
- },
116
- /**
117
- * Fetches asset content or a preview from the CDN with the client's API key, and returns
118
- * the `Response` as soon as headers arrive, so the body streams. Non-2xx responses are
119
- * returned, not thrown; `timeoutMs` covers only the wait for headers.
120
- */
121
- serve: async ({ assetIdentifier, init, ...urlOptions }) => {
122
- const url = this.cdn.getAssetUrl({
123
- assetIdentifier,
124
- ...urlOptions
125
- });
126
- const method = init?.method?.toUpperCase() ?? "GET";
127
- const { response } = await this.#connection.transport.send({
128
- method,
129
- url,
130
- init,
131
- timeoutMs: this.#connection.timeoutMs,
132
- deadline: "headers",
133
- fetch: this.#settings.cdnFetch
134
- }, { allowHttpErrors: true });
135
- return response;
136
- }
137
- };
138
- }
139
- get text() {
140
- return {
141
- /**
142
- * Moderates text and waits for the decision. Sends `text` as `text/plain` content named
143
- * `text` unless you pass `name`.
144
- */
145
- moderate: async (options) => this.buildAssets().moderate(toTextAssetOptions(options)) };
146
- }
147
- /** Assets: uploads, moderation and management. */
148
- get assets() {
149
- return this.buildAssets();
150
- }
151
- /**
152
- * Signs upload tokens on your server, so a browser or app can upload through an upload preset
153
- * that requires one. Signing happens locally with your API key: it makes no request.
154
- */
155
- get uploadTokens() {
156
- return {
157
- /**
158
- * Signs a single-use token for one upload through `uploadPresetId`. Give `token` to the
159
- * browser or app, which passes it to `CalmLensAppClient` as `token`.
160
- */
161
- sign: async (options) => {
162
- return signUploadTokenWithApiKey(this.#apiKey, options);
163
- } };
164
- }
165
- buildAssets() {
166
- return {
167
- /** Searches accessible assets by keyword or a natural-language description. */
168
- search: async (options) => this.request("GET", `${this.projectPath}/assets/search`, { query: options }),
169
- /** Lists one page of the project's assets, with optional filter and ordering. */
170
- list: async (options = {}) => this.request("GET", `${this.projectPath}/assets/page`, { query: options }),
171
- /**
172
- * Yields every asset matching `options`, fetching pages as needed. The cursor keeps the position
173
- * stable when other assets are inserted or deleted.
174
- */
175
- iterate: (options = {}) => this.iteratePages((cursor) => this.buildAssets().list({
176
- ...options,
177
- cursor
178
- })),
179
- /** Retrieves an asset with its status and moderation report. */
180
- retrieve: async ({ assetId }) => this.request("GET", `/assets/${requireId(assetId, "Asset ID")}`),
181
- /** Updates an asset's editable fields. */
182
- update: async ({ assetId, ...body }) => this.request("PATCH", `/assets/${requireId(assetId, "Asset ID")}`, { body }),
183
- /** Lists one page of the assets extracted from a website or multimedia document. */
184
- listChildren: async ({ assetId, ...options } = {}) => this.request("GET", `/assets/${requireId(assetId, "Asset ID")}/children`, { query: options }),
185
- /** Yields every child of an asset, fetching pages as needed. */
186
- iterateChildren: (options) => {
187
- requireId(options?.assetId, "Asset ID");
188
- return this.iteratePages((cursor) => this.buildAssets().listChildren({
189
- ...options,
190
- cursor
191
- }));
192
- },
193
- /**
194
- * Uploads an asset and queues it for moderation, returning immediately with processing
195
- * status `processing` (or `uploading` for the first multipart part). Retried safely with an
196
- * `Idempotency-Key`.
197
- */
198
- create: async (options) => this.submitAsset(options, true),
199
- /**
200
- * Uploads an asset and waits for its moderation decision. Audio and video still finish
201
- * in the background. Retried safely with an `Idempotency-Key`.
202
- */
203
- moderate: async (options) => {
204
- if (options && "content" in options && "plainText" in options.content && (typeof options.content.plainText !== "string" || !options.content.plainText.trim())) throw invalidPayload("Text to moderate must be a non-empty string");
205
- return this.submitAsset(options, false);
206
- },
207
- /**
208
- * Uploads the next part of a multipart upload. The upload's queue mode, chosen when it
209
- * started, applies when the final part arrives; pass `queue` to override it.
210
- */
211
- uploadPart: async ({ assetId, ...options }) => {
212
- const encodedAssetId = requireId(assetId, "Asset ID");
213
- return expectAsset(await this.#connection.uploadPart(`/assets/${encodedAssetId}/part`, options));
214
- },
215
- /**
216
- * Creates a short-lived signed CDN URL, so browsers can load private content without
217
- * an API key.
218
- */
219
- createSignedUrl: async ({ assetId, ...options }) => {
220
- const encodedAssetId = requireId(assetId, "Asset ID");
221
- return this.request("POST", `/assets/${encodedAssetId}/signed-url`, { body: {
222
- resource: options.resource ?? "content",
223
- expiresInSeconds: options.expiresInSeconds ?? 900,
224
- lens: options.lens,
225
- transform: transformSelector(options.transform)
226
- } });
227
- },
228
- /** Moves an asset into a folder, or to the project root with `folderId: null`. */
229
- move: async ({ assetId, folderId }) => this.request("POST", `/assets/${requireId(assetId, "Asset ID")}/move`, { body: { folderId } }),
230
- /**
231
- * Processes an asset that failed (processing status `failed`) again. It returns to
232
- * `processing` and is
233
- * moderated like a new upload; its content must still be stored.
234
- */
235
- retry: async ({ assetId }) => this.request("POST", `/assets/${requireId(assetId, "Asset ID")}/retry`),
236
- /**
237
- * Waits until an asset's `moderation.status` is `approved` or `rejected`, or its
238
- * `processing.status` is `failed`, and returns that asset, checking every few seconds. Rejects with `sdk/timeout` if there is no
239
- * decision within `timeoutMs` (default 60000). Webhooks report decisions without polling.
240
- */
241
- waitForDecision: async ({ assetId, ...options }) => {
242
- requireId(assetId, "Asset ID");
243
- const client = options.signal ? this.withRequestOptions({ signal: options.signal }) : this;
244
- return pollForDecision(() => client.assets.retrieve({ assetId }), options, sleep);
245
- },
246
- /**
247
- * Removes an asset's stored content (source, preview and retained extracted text) while
248
- * keeping the asset record, metadata and moderation report. The returned asset has
249
- * `storageDeletedAt` set; the CDN no longer serves it.
250
- */
251
- deleteContent: async ({ assetId }) => this.request("DELETE", `/assets/${requireId(assetId, "Asset ID")}/content`),
252
- /** Deletes an asset record together with its stored content. */
253
- delete: async ({ assetId }) => {
254
- await this.request("DELETE", `/assets/${requireId(assetId, "Asset ID")}`);
255
- }
256
- };
257
- }
258
- get events() {
259
- return {
260
- /**
261
- * Lists project events after a cursor, oldest first. Call without `after` to get a cursor
262
- * for "now", then poll with the returned `cursor`. Webhook deliveries carry the same events
263
- * reshaped as `{ id, type, createdAt, projectId, data }`.
264
- */
265
- list: async (options = {}) => this.request("GET", `${this.projectPath}/events`, { query: { ...options } }),
266
- /**
267
- * Yields pages of events after `after` until no more are waiting. Save each page's
268
- * `cursor` to resume later. Without `after`, yields one empty page whose cursor is "now".
269
- */
270
- pages: (options = {}) => this.iterateEventPages(options)
271
- };
272
- }
273
- async *iterateEventPages(options) {
274
- let after = options.after;
275
- for (;;) {
276
- const page = await this.events.list({
277
- ...options,
278
- after
279
- });
280
- yield page;
281
- if (!page.hasMore || page.cursor === after) return;
282
- after = page.cursor;
283
- }
284
- }
285
- async *iteratePages(listPage) {
286
- let cursor;
287
- for (;;) {
288
- const page = await listPage(cursor);
289
- yield* page.items;
290
- if (!page.nextCursor || !page.hasMore || page.items.length === 0) return;
291
- cursor = page.nextCursor;
292
- }
293
- }
294
- get folders() {
295
- return {
296
- /** Lists every folder in the project. */
297
- list: async () => (await this.request("GET", `${this.projectPath}/folders`)).folders,
298
- retrieve: async ({ folderId }) => this.request("GET", `/folders/${requireId(folderId, "Folder ID")}`),
299
- /** Creates a folder at the project root or inside `parentId`. */
300
- create: async (options) => this.request("POST", `${this.projectPath}/folders`, { body: options }),
301
- update: async ({ folderId, ...body }) => this.request("PATCH", `/folders/${requireId(folderId, "Folder ID")}`, { body }),
302
- /** Deletes an empty folder; throws `folder/not-empty` otherwise. */
303
- delete: async ({ folderId }) => {
304
- await this.request("DELETE", `/folders/${requireId(folderId, "Folder ID")}`);
305
- },
306
- /** Moves a folder under `parentId`, or to the project root with `parentId: null`. */
307
- move: async ({ folderId, parentId }) => this.request("POST", `/folders/${requireId(folderId, "Folder ID")}/move`, { body: { parentId } })
308
- };
309
- }
310
- /**
311
- * Create a named lens at any positive version, or update it with a greater version.
312
- * Supplying the same definition and version is idempotent.
313
- */
314
- async ensureLens(options) {
315
- if (!options?.identifier) throw invalidArgument("Lens identifier is required");
316
- const [lens] = await this.ensureLenses({ lenses: [options] });
317
- return lens;
318
- }
319
- /**
320
- * Create or update several named lenses. New definitions are sent in one API request, or in
321
- * consecutive requests of at most {@link ENSURE_LENSES_MAX_PER_REQUEST} definitions, in order.
322
- * Each lens follows the same versioning and idempotency rules as ensureLens().
323
- */
324
- async ensureLenses({ lenses }) {
325
- if (!Array.isArray(lenses) || lenses.length === 0) throw invalidArgument("At least one lens is required");
326
- if (lenses.some((option) => !option?.identifier)) throw invalidArgument("Lens identifier is required");
327
- const { ensuredLenses, pendingEnsureLenses } = this.#lenses;
328
- const missingOptions = [];
329
- const missingKeys = /* @__PURE__ */ new Set();
330
- for (const option of lenses) {
331
- const cacheKey = getEnsureLensCacheKey(option);
332
- if (!ensuredLenses.has(cacheKey) && !missingKeys.has(cacheKey)) {
333
- missingKeys.add(cacheKey);
334
- missingOptions.push(option);
335
- }
336
- }
337
- if (missingOptions.length > 0) {
338
- const batchCacheKey = JSON.stringify([...missingKeys].sort());
339
- let pending = pendingEnsureLenses.get(batchCacheKey);
340
- if (!pending) {
341
- pending = this.submitEnsureLenses(missingOptions);
342
- pendingEnsureLenses.set(batchCacheKey, pending);
343
- pending.then(() => pendingEnsureLenses.delete(batchCacheKey), () => pendingEnsureLenses.delete(batchCacheKey));
344
- }
345
- await pending;
346
- }
347
- return lenses.map((option) => {
348
- const lens = ensuredLenses.get(getEnsureLensCacheKey(option));
349
- if (!lens) throw new CalmLensError(SDK_ERROR_CODES.INVALID_RESPONSE, { message: `CalmLens did not return lens "${option.identifier}"` });
350
- return lens;
351
- });
352
- }
353
- async submitEnsureLenses(options) {
354
- for (let start = 0; start < options.length; start += 100) await this.submitEnsureLensesChunk(options.slice(start, start + 100));
355
- }
356
- async submitEnsureLensesChunk(options) {
357
- const result = await this.request("PUT", `${this.projectPath}/lenses`, { body: { lenses: options } });
358
- const returned = Array.isArray(result?.lenses) ? result.lenses : [];
359
- if (returned.length !== options.length) throw new CalmLensError(SDK_ERROR_CODES.INVALID_RESPONSE, { message: `Expected ${options.length} lenses but CalmLens returned ${returned.length}` });
360
- const { ensuredLenses } = this.#lenses;
361
- for (const [index, lens] of returned.entries()) {
362
- const optionsForLens = options[index];
363
- if (lens?.identifier !== optionsForLens.identifier) throw new CalmLensError(SDK_ERROR_CODES.INVALID_RESPONSE, { message: `Expected lens "${optionsForLens.identifier}" at position ${index}` });
364
- for (const [cacheKey, cachedLens] of ensuredLenses) if (cachedLens.identifier === optionsForLens.identifier) ensuredLenses.delete(cacheKey);
365
- ensuredLenses.set(getEnsureLensCacheKey(optionsForLens), lens);
366
- }
367
- }
368
- };
369
- //#endregion
370
- export { CALMLENS_ERROR_CODES, CalmLensError, CalmLensServerClient, SDK_VERSION, WEBHOOK_DELIVERY_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, WEBHOOK_TIMESTAMP_HEADER, parseWebhookEvent, verifyWebhookSignature };
3
+ import { a as WEBHOOK_TIMESTAMP_HEADER, c as verifyWebhookSignature, i as WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, n as WEBHOOK_DELIVERY_HEADER, o as isTestWebhookEvent, r as WEBHOOK_SIGNATURE_HEADER, s as parseWebhookEvent, t as CalmLensServerClient } from "./CalmLensServerClient-D80_mKpW.js";
4
+ export { CALMLENS_ERROR_CODES, CalmLensError, CalmLensServerClient, DEFAULT_CDN_BASE_URL, MODERATION_CONTEXT_USED_CHARACTERS, MODERATION_CONTEXT_USED_ITEMS, SDK_VERSION, WEBHOOK_DELIVERY_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, WEBHOOK_TIMESTAMP_HEADER, getAssetUrl, isTestWebhookEvent, parseWebhookEvent, selectModerationContextItems, verifyWebhookSignature };
package/esm/styles.css ADDED
@@ -0,0 +1,147 @@
1
+ /* Zero specificity, so a page's own --calmlens-* values win even on the same element. */
2
+ :where(.calmlens-uploader, .calmlens-media) {
3
+ --calmlens-accent: #635bff;
4
+ --calmlens-accent-text: #fff;
5
+ --calmlens-radius: 12px;
6
+ --calmlens-border: color-mix(in srgb, currentColor 18%, transparent);
7
+ --calmlens-surface: color-mix(in srgb, currentColor 4%, transparent);
8
+ --calmlens-background: transparent;
9
+ --calmlens-muted: color-mix(in srgb, currentColor 72%, transparent);
10
+ --calmlens-success: #12a26f;
11
+ --calmlens-warning: #e8a317;
12
+ --calmlens-danger: #e5484d;
13
+ --calmlens-focus-ring: var(--calmlens-accent);
14
+ --calmlens-focus-width: 2px;
15
+ --calmlens-focus-offset: 2px;
16
+ box-sizing: border-box;
17
+ font: inherit;
18
+ }
19
+ .calmlens-uploader *, .calmlens-media * { box-sizing: border-box; }
20
+ .calmlens-uploader { display: grid; gap: 12px; }
21
+ .calmlens-uploader > [hidden] { display: none; }
22
+ .calmlens-dropzone {
23
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;
24
+ padding: 40px 24px; text-align: center;
25
+ border: 1.5px dashed var(--calmlens-border); border-radius: var(--calmlens-radius);
26
+ background: var(--calmlens-surface); cursor: pointer;
27
+ transition: border-color 200ms ease-in-out, background-color 200ms ease-in-out;
28
+ }
29
+ .calmlens-dropzone:hover, .calmlens-dropzone:focus-within {
30
+ border-color: color-mix(in srgb, var(--calmlens-accent) 30%, transparent);
31
+ background: color-mix(in srgb, var(--calmlens-accent) 4%, transparent);
32
+ }
33
+ .calmlens-dropzone[data-dragging] {
34
+ border-color: var(--calmlens-accent);
35
+ background: color-mix(in srgb, var(--calmlens-accent) 9%, transparent);
36
+ }
37
+ .calmlens-dropzone[data-disabled] {
38
+ cursor: not-allowed; border-color: var(--calmlens-border); background: none; color: var(--calmlens-muted);
39
+ }
40
+ .calmlens-dropzone__icon {
41
+ display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%;
42
+ color: var(--calmlens-accent); background: color-mix(in srgb, var(--calmlens-accent) 10%, transparent);
43
+ transition: transform 200ms;
44
+ }
45
+ .calmlens-dropzone[data-disabled] .calmlens-dropzone__icon { color: inherit; background: var(--calmlens-surface); }
46
+ .calmlens-dropzone[data-dragging] .calmlens-dropzone__icon { transform: scale(1.1); }
47
+ .calmlens-dropzone__text { display: grid; gap: 4px; }
48
+ .calmlens-dropzone__title { font-weight: 500; margin: 0; }
49
+ .calmlens-dropzone__hint { font-size: 0.875em; margin: 0; color: var(--calmlens-muted); }
50
+ .calmlens-button, .calmlens-icon-button { margin: 0; width: auto; text-transform: none; letter-spacing: normal; }
51
+ .calmlens-button {
52
+ font: inherit; font-weight: 500; font-size: 0.875em; padding: 7px 14px; border-radius: 8px; cursor: pointer;
53
+ border: 1px solid var(--calmlens-accent); background: var(--calmlens-accent); color: var(--calmlens-accent-text);
54
+ white-space: nowrap; transition: background-color 150ms, border-color 150ms;
55
+ }
56
+ .calmlens-button:focus-visible, .calmlens-icon-button:focus-visible {
57
+ outline: var(--calmlens-focus-width) solid var(--calmlens-focus-ring); outline-offset: var(--calmlens-focus-offset);
58
+ }
59
+ .calmlens-button:hover:not(:disabled) { background: var(--calmlens-accent); filter: brightness(1.08); }
60
+ .calmlens-button[data-variant="outline"] { background: none; color: inherit; border-color: var(--calmlens-border); }
61
+ .calmlens-button[data-variant="outline"]:hover:not(:disabled) {
62
+ filter: none; background: color-mix(in srgb, var(--calmlens-accent) 8%, transparent); border-color: var(--calmlens-accent);
63
+ }
64
+ .calmlens-button[data-size="small"] { padding: 4px 10px; }
65
+ .calmlens-button:disabled {
66
+ cursor: not-allowed; border-style: dashed; background: none; color: var(--calmlens-muted); border-color: var(--calmlens-border);
67
+ }
68
+ .calmlens-uploads { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
69
+ .calmlens-upload {
70
+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px;
71
+ border: 1px solid var(--calmlens-border); border-radius: var(--calmlens-radius); background: var(--calmlens-background);
72
+ }
73
+ /* A finished file's outcome is a dot in its status colour before the message. */
74
+ .calmlens-upload[data-outcome] .calmlens-upload__message::before {
75
+ content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 50%;
76
+ vertical-align: 0.05em;
77
+ }
78
+ .calmlens-upload[data-outcome="accepted"] .calmlens-upload__message::before { background: var(--calmlens-success); }
79
+ .calmlens-upload[data-outcome="restricted"] .calmlens-upload__message::before { background: var(--calmlens-warning); }
80
+ .calmlens-upload[data-outcome="rejected"] .calmlens-upload__message::before { background: var(--calmlens-danger); }
81
+ .calmlens-upload__thumb {
82
+ width: 40px; height: 40px; border-radius: 8px; object-fit: cover; display: grid; place-items: center;
83
+ background: var(--calmlens-surface); border: 1px solid var(--calmlens-border);
84
+ font-size: 0.6875em; font-weight: 700; text-transform: uppercase; overflow: hidden;
85
+ }
86
+ .calmlens-upload__thumb[data-kind="file"] { background: none; border: none; }
87
+ .calmlens-upload[data-outcome="rejected"] img.calmlens-upload__thumb { filter: blur(6px); }
88
+ .calmlens-upload__body { min-width: 0; display: grid; gap: 4px; }
89
+ .calmlens-upload__name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
90
+ .calmlens-upload__size { font-size: 0.875em; color: var(--calmlens-muted); }
91
+ .calmlens-upload__message { font-size: 0.875em; margin: 0; }
92
+ .calmlens-progress {
93
+ height: 4px; border-radius: 2px; overflow: hidden; background: var(--calmlens-border); position: relative;
94
+ }
95
+ .calmlens-progress__bar {
96
+ position: absolute; inset: 0 auto 0 0; background: var(--calmlens-accent); border-radius: inherit;
97
+ transition: width 150ms;
98
+ }
99
+ .calmlens-progress:not([aria-valuenow]) .calmlens-progress__bar {
100
+ width: 35%; animation: calmlens-indeterminate 1.1s ease-in-out infinite;
101
+ }
102
+ @keyframes calmlens-indeterminate { from { left: -35%; } to { left: 100%; } }
103
+ .calmlens-upload__actions { display: flex; gap: 8px; align-items: center; }
104
+ .calmlens-icon-button {
105
+ display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border-radius: 8px; cursor: pointer;
106
+ border: 1px solid transparent; background: none; color: inherit;
107
+ }
108
+ .calmlens-icon-button:hover { background: color-mix(in srgb, var(--calmlens-accent) 8%, transparent); border-color: var(--calmlens-border); }
109
+ @media (prefers-reduced-motion: reduce) {
110
+ .calmlens-progress__bar, .calmlens-dropzone, .calmlens-dropzone__icon, .calmlens-media__content { transition: none; }
111
+ .calmlens-progress:not([aria-valuenow]) .calmlens-progress__bar { animation: none; left: 0; width: 100%; }
112
+ }
113
+
114
+ .calmlens-media {
115
+ position: relative; display: block; overflow: hidden; margin: 0; border-radius: var(--calmlens-media-radius, 0);
116
+ background: var(--calmlens-surface);
117
+ }
118
+ .calmlens-media__placeholder, .calmlens-media__content {
119
+ position: absolute; inset: 0; width: 100%; height: 100%; object-fit: var(--calmlens-fit, cover);
120
+ object-position: var(--calmlens-position, center);
121
+ }
122
+ .calmlens-media:not([data-sized]) .calmlens-media__content { position: relative; height: auto; }
123
+ .calmlens-media:not([data-sized])[data-kind="gated"], .calmlens-media:not([data-sized])[data-kind="unavailable"],
124
+ .calmlens-media:not([data-sized])[data-state="error"] { min-height: 160px; }
125
+ .calmlens-media__placeholder { transform: scale(1.1); filter: blur(12px); }
126
+ .calmlens-media__content { opacity: 0; transition: opacity 250ms; }
127
+ .calmlens-media[data-state="loaded"] .calmlens-media__content { opacity: 1; }
128
+ .calmlens-media[data-kind="video"] .calmlens-media__content, .calmlens-media[data-kind="audio"] .calmlens-media__content { opacity: 1; }
129
+ .calmlens-media[data-kind="audio"] { background: none; }
130
+ .calmlens-media[data-kind="audio"] .calmlens-media__content { position: static; height: auto; }
131
+ .calmlens-media__notice {
132
+ position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 6px;
133
+ padding: 12px; text-align: center; color: #fff; background: rgba(0, 0, 0, 0.45);
134
+ }
135
+ .calmlens-media__notice p { margin: 0; display: flex; gap: 6px; align-items: center; }
136
+ /* Thumbnails keep the notice's icon and leave its text to screen readers. */
137
+ .calmlens-media[data-sized] { container-type: inline-size; }
138
+ @container (max-width: 119px) {
139
+ .calmlens-media__label, .calmlens-media__notice > strong, .calmlens-media__notice > p:not(:has(svg)) {
140
+ position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
141
+ }
142
+ }
143
+ .calmlens-media__notice strong { font-weight: 600; }
144
+ .calmlens-media__notice .calmlens-button { margin-top: 4px; border-color: #fff; background: #fff; color: #111; }
145
+ .calmlens-media[data-state="error"] .calmlens-media__notice, .calmlens-media[data-kind="unavailable"] .calmlens-media__notice {
146
+ color: inherit; background: var(--calmlens-surface);
147
+ }
@@ -0,0 +1,134 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import {
4
+ getMediaAspectRatio,
5
+ getMediaLoadKey,
6
+ getMediaState,
7
+ getMediaUnavailableMessage,
8
+ type MediaOptions,
9
+ type MediaProps,
10
+ resolveMediaProps,
11
+ resolveMessages,
12
+ } from "../ui.js";
13
+ import Icon from "./Icon.svelte";
14
+
15
+ let {
16
+ alt = "",
17
+ aspectRatio,
18
+ width,
19
+ height,
20
+ sizes,
21
+ averageColor,
22
+ fit,
23
+ position,
24
+ lazy = true,
25
+ fetchPriority,
26
+ controls = true,
27
+ messages: overrides,
28
+ onReveal,
29
+ ...options
30
+ }: MediaProps = $props();
31
+
32
+ const messages = $derived(resolveMessages(overrides));
33
+ // An `asset` fills in what isn't passed: the source, status and type, and its preview's size.
34
+ const view = $derived(
35
+ resolveMediaProps({ ...(options as MediaOptions), width, height, averageColor }),
36
+ );
37
+ const state = $derived(getMediaState(view));
38
+ const loadKey = $derived(getMediaLoadKey(state));
39
+ const ratio = $derived(
40
+ getMediaAspectRatio({ aspectRatio, width: view.width, height: view.height }),
41
+ );
42
+ let load: "loading" | "loaded" | "error" = $state("loading");
43
+ let image: HTMLImageElement | undefined = $state();
44
+
45
+ $effect.pre(() => {
46
+ void loadKey;
47
+ load = "loading";
48
+ });
49
+ onMount(() => {
50
+ // An image that loaded before hydration fires no load event.
51
+ if (image?.complete && image.naturalWidth > 0) load = "loaded";
52
+ });
53
+
54
+ const onError = () => {
55
+ load = "error";
56
+ };
57
+ const placeholderUrl = $derived(
58
+ state.kind === "audio" || state.kind === "unavailable" ? undefined : state.placeholderUrl,
59
+ );
60
+ const showPlaceholder = $derived(
61
+ placeholderUrl && (state.kind === "gated" || (state.kind === "image" && load !== "loaded")),
62
+ );
63
+ const failed = $derived(load === "error");
64
+ </script>
65
+
66
+ <figure
67
+ class="calmlens-media"
68
+ data-kind={state.kind}
69
+ data-state={load}
70
+ data-sized={ratio ? "" : undefined}
71
+ style:aspect-ratio={ratio}
72
+ style:background-color={view.averageColor}
73
+ style:--calmlens-fit={fit}
74
+ style:--calmlens-position={position}
75
+ >
76
+ {#if showPlaceholder}
77
+ <img class="calmlens-media__placeholder" src={placeholderUrl} alt="" aria-hidden="true" />
78
+ {/if}
79
+ {#if state.kind === "image" && !failed}
80
+ {#key loadKey}
81
+ <img
82
+ bind:this={image}
83
+ class="calmlens-media__content"
84
+ src={state.src}
85
+ srcset={state.srcSet}
86
+ {sizes}
87
+ {alt}
88
+ width={view.width}
89
+ height={view.height}
90
+ loading={lazy ? "lazy" : undefined}
91
+ decoding="async"
92
+ fetchpriority={fetchPriority}
93
+ onload={() => (load = "loaded")}
94
+ onerror={onError}
95
+ />
96
+ {/key}
97
+ {:else if state.kind === "video" && !failed}
98
+ <!-- svelte-ignore a11y_media_has_caption -->
99
+ <!-- biome-ignore lint/a11y/useMediaCaption: captions are your app's to add -->
100
+ <video
101
+ class="calmlens-media__content"
102
+ src={state.src}
103
+ {controls}
104
+ playsinline
105
+ preload="metadata"
106
+ aria-label={alt || undefined}
107
+ onerror={() => (load = "error")}
108
+ ></video>
109
+ {:else if state.kind === "audio" && !failed}
110
+ <!-- biome-ignore lint/a11y/useMediaCaption: audio has no captions track to add here -->
111
+ <audio
112
+ class="calmlens-media__content"
113
+ src={state.src}
114
+ {controls}
115
+ preload="metadata"
116
+ aria-label={alt || undefined}
117
+ onerror={() => (load = "error")}
118
+ ></audio>
119
+ {/if}
120
+ {#if state.kind === "gated"}
121
+ <figcaption class="calmlens-media__notice">
122
+ <strong>{messages.sensitiveTitle}</strong>
123
+ <p>{messages.sensitiveBody}</p>
124
+ {#if onReveal}
125
+ <button type="button" class="calmlens-button" onclick={onReveal}>{messages.reveal}</button>
126
+ {/if}
127
+ </figcaption>
128
+ {/if}
129
+ {#if state.kind === "unavailable" || failed}
130
+ <figcaption class="calmlens-media__notice">
131
+ <p><Icon name="unavailable" /><span class="calmlens-media__label">{getMediaUnavailableMessage(state, messages)}</span></p>
132
+ </figcaption>
133
+ {/if}
134
+ </figure>