sanity-plugin-image-resizer 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tristan Bagot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # sanity-plugin-image-resizer
2
+
3
+ > This is a **Sanity Studio v3** plugin.
4
+
5
+ Batch-optimise image assets in your Sanity dataset. Scans all `sanity.imageAsset` documents for constraint violations (TIFF format, oversized width or filesize) and lets editors resize, compress and convert them in-place — re-encoding via the Sanity Image API, uploading the optimised version, re-linking all references and cleaning up the old asset.
6
+
7
+ ## Features
8
+
9
+ - **Format conversion** — TIFF → WebP (or JPG), optional PNG → WebP
10
+ - **Resize** — caps images to a configurable max width
11
+ - **Compress** — progressively lowers quality until the file fits within the size budget
12
+ - **Batch processing** — process all violating assets in parallel
13
+ - **Reference patching** — automatically updates every document referencing the old asset
14
+ - **Configurable** — override accepted types, max size and max width from plugin config
15
+ - **Validation helper** — export a `validateImageSize` custom validator for your schemas
16
+
17
+ ## Installation
18
+
19
+ ```sh
20
+ npm install sanity-plugin-image-resizer
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ Add it as a plugin in `sanity.config.ts` (or .js):
26
+
27
+ ```ts
28
+ import { defineConfig } from 'sanity'
29
+ import { imageResizerPlugin } from 'sanity-plugin-image-resizer'
30
+
31
+ export default defineConfig({
32
+ // ...
33
+ plugins: [
34
+ imageResizerPlugin({
35
+ // All options are optional — these are the defaults:
36
+ imageAccept: 'image/jpeg, image/png, image/gif, image/webp',
37
+ imageMaxSize: 20 * 1024 * 1024, // 20 MB
38
+ imageMaxWidth: 6000,
39
+ }),
40
+ ],
41
+ })
42
+ ```
43
+
44
+ ### Image validation in schemas
45
+
46
+ The plugin exports a `validateImageSize` custom validator you can attach to any image field to enforce the same constraints at document level:
47
+
48
+ ```ts
49
+ import { defineType, defineField } from 'sanity'
50
+ import { validateImageSize } from 'sanity-plugin-image-resizer'
51
+
52
+ export default defineType({
53
+ name: 'myDocument',
54
+ type: 'document',
55
+ fields: [
56
+ defineField({
57
+ name: 'photo',
58
+ type: 'image',
59
+ validation: (rule) => rule.custom(validateImageSize),
60
+ }),
61
+ ],
62
+ })
63
+ ```
64
+
65
+ ### Using the tool
66
+
67
+ 1. Open your Sanity Studio.
68
+ 2. Navigate to the **Image Optimiser** tool in the Studio sidebar.
69
+ 3. The tool automatically scans all image assets and shows those violating constraints.
70
+ 4. Click **Process All** to batch-optimise, or process individual assets.
71
+ 5. Use the ⚙ Settings button to toggle PNG → WebP and TIFF → JPG conversions.
72
+
73
+ ## Configuration options
74
+
75
+ | Option | Type | Default | Description |
76
+ | --------------- | -------- | ------------------------------------------------ | ------------------------------------- |
77
+ | `imageAccept` | `string` | `'image/jpeg, image/png, image/gif, image/webp'` | Accepted MIME types (comma-separated) |
78
+ | `imageMaxSize` | `number` | `20971520` (20 MB) | Max file size in bytes |
79
+ | `imageMaxWidth` | `number` | `6000` | Max image width in pixels |
80
+
81
+ ## License
82
+
83
+ [MIT](LICENSE) © Tristan Bagot
84
+
85
+ ## Develop & test
86
+
87
+ This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit)
88
+ with default configuration for build & watch scripts.
89
+
90
+ See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio)
91
+ on how to run this plugin with hotreload in the studio.
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ var resources = {};
3
+ exports.default = resources;
4
+ //# sourceMappingURL=resources.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resources.js","sources":["../../src/i18n/resources.ts"],"sourcesContent":["export default {}\n"],"names":[],"mappings":";AAAA,IAAA,YAAe,CAAA;;"}
@@ -0,0 +1,5 @@
1
+ var resources = {};
2
+ export {
3
+ resources as default
4
+ };
5
+ //# sourceMappingURL=resources.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resources.mjs","sources":["../../src/i18n/resources.ts"],"sourcesContent":["export default {}\n"],"names":[],"mappings":"AAAA,IAAA,YAAe,CAAA;"}
@@ -0,0 +1,31 @@
1
+ import { CustomValidator } from 'sanity'
2
+ import { Plugin as Plugin_2 } from 'sanity'
3
+
4
+ /** @public */
5
+ export declare let IMAGE_ACCEPT: string
6
+
7
+ /** @public */
8
+ export declare let IMAGE_MAX_SIZE: number
9
+
10
+ /** @public */
11
+ export declare let IMAGE_MAX_WIDTH: number
12
+
13
+ /** @public */
14
+ export declare interface ImageResizerOptions {
15
+ /** Accepted image MIME types (comma-separated). Default: 'image/jpeg, image/png, image/gif, image/webp' */
16
+ imageAccept?: string
17
+ /** Max file size in bytes. Default: 20 * 1024 * 1024 (20 MB) */
18
+ imageMaxSize?: number
19
+ /** Max image width in pixels. Default: 10000 */
20
+ imageMaxWidth?: number
21
+ }
22
+
23
+ /**
24
+ * @public
25
+ */
26
+ export declare const imageResizerPlugin: Plugin_2<void | ImageResizerOptions>
27
+
28
+ /** @public */
29
+ export declare const validateImageSize: CustomValidator
30
+
31
+ export {}
@@ -0,0 +1,31 @@
1
+ import { CustomValidator } from 'sanity'
2
+ import { Plugin as Plugin_2 } from 'sanity'
3
+
4
+ /** @public */
5
+ export declare let IMAGE_ACCEPT: string
6
+
7
+ /** @public */
8
+ export declare let IMAGE_MAX_SIZE: number
9
+
10
+ /** @public */
11
+ export declare let IMAGE_MAX_WIDTH: number
12
+
13
+ /** @public */
14
+ export declare interface ImageResizerOptions {
15
+ /** Accepted image MIME types (comma-separated). Default: 'image/jpeg, image/png, image/gif, image/webp' */
16
+ imageAccept?: string
17
+ /** Max file size in bytes. Default: 20 * 1024 * 1024 (20 MB) */
18
+ imageMaxSize?: number
19
+ /** Max image width in pixels. Default: 10000 */
20
+ imageMaxWidth?: number
21
+ }
22
+
23
+ /**
24
+ * @public
25
+ */
26
+ export declare const imageResizerPlugin: Plugin_2<void | ImageResizerOptions>
27
+
28
+ /** @public */
29
+ export declare const validateImageSize: CustomValidator
30
+
31
+ export {}
package/dist/index.js ADDED
@@ -0,0 +1,487 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: !0 });
3
+ var sanity = require("sanity"), jsxRuntime = require("react/jsx-runtime"), react = require("react"), ui = require("@sanity/ui"), icons = require("@sanity/icons");
4
+ const imageResizerLocaleNamespace = "image-resizer", imageResizerUsEnglishLocaleBundle = sanity.defineLocaleResourceBundle({
5
+ locale: "en-US",
6
+ namespace: imageResizerLocaleNamespace,
7
+ resources: () => Promise.resolve().then(function() {
8
+ return require("./_chunks-cjs/resources.js");
9
+ })
10
+ }), DEFAULT_SETTINGS = {
11
+ pngToWebp: !1,
12
+ tiffToJpg: !1
13
+ }, DEFAULTS = {
14
+ imageAccept: "image/jpeg, image/png, image/gif, image/webp",
15
+ imageMaxSize: 20 * 1024 * 1024,
16
+ imageMaxWidth: 6e3
17
+ };
18
+ exports.IMAGE_ACCEPT = DEFAULTS.imageAccept;
19
+ exports.IMAGE_MAX_SIZE = DEFAULTS.imageMaxSize;
20
+ exports.IMAGE_MAX_WIDTH = DEFAULTS.imageMaxWidth;
21
+ function applyConfig(options) {
22
+ const resolved = { ...DEFAULTS, ...options };
23
+ exports.IMAGE_ACCEPT = resolved.imageAccept, exports.IMAGE_MAX_SIZE = resolved.imageMaxSize, exports.IMAGE_MAX_WIDTH = resolved.imageMaxWidth;
24
+ }
25
+ const MIME_FORMAT_MAP = {
26
+ "image/jpeg": "jpg",
27
+ "image/png": "png",
28
+ "image/webp": "webp",
29
+ "image/gif": "gif"
30
+ }, QUALITY_STEPS = [92, 80, 70, 60, 50, 40], CONCURRENCY = 3;
31
+ function getViolations(asset, settings) {
32
+ const v = [];
33
+ return asset.mimeType === "image/tiff" && v.push("format"), settings.pngToWebp && asset.mimeType === "image/png" && v.push("format"), asset.width > exports.IMAGE_MAX_WIDTH && v.push("width"), asset.size > exports.IMAGE_MAX_SIZE && v.push("size"), v;
34
+ }
35
+ function outputFormat(inputMimeType, settings) {
36
+ return inputMimeType === "image/tiff" ? settings.tiffToJpg ? "jpg" : "webp" : inputMimeType === "image/png" && settings.pngToWebp ? "webp" : MIME_FORMAT_MAP[inputMimeType] ?? "png";
37
+ }
38
+ async function processImage(url, inputMimeType, currentWidth, settings) {
39
+ const outFormat = outputFormat(inputMimeType, settings), transformUrl = new URL(url);
40
+ transformUrl.searchParams.set("fm", outFormat), transformUrl.searchParams.set(
41
+ "w",
42
+ String(Math.min(currentWidth, exports.IMAGE_MAX_WIDTH))
43
+ );
44
+ const maxSizeMB = exports.IMAGE_MAX_SIZE / 1024 / 1024;
45
+ for (const q of QUALITY_STEPS) {
46
+ transformUrl.searchParams.set("q", String(q));
47
+ const res = await fetch(transformUrl.toString());
48
+ if (!res.ok) throw new Error(`Fetch failed (HTTP ${res.status})`);
49
+ const blob = await res.blob();
50
+ if (blob.size <= exports.IMAGE_MAX_SIZE)
51
+ return { blob, outFormat };
52
+ }
53
+ throw new Error(`Cannot compress image below ${maxSizeMB} MB`);
54
+ }
55
+ function buildReplacementPatch(obj, oldId, newId, path = "") {
56
+ if (!obj || typeof obj != "object") return {};
57
+ if (Array.isArray(obj))
58
+ return obj.reduce(
59
+ (acc, item, i) => {
60
+ const key = item && typeof item == "object" && typeof item._key == "string" ? `_key=="${item._key}"` : String(i), childPath = path ? `${path}[${key}]` : `[${key}]`;
61
+ return Object.assign(
62
+ acc,
63
+ buildReplacementPatch(item, oldId, newId, childPath)
64
+ );
65
+ },
66
+ {}
67
+ );
68
+ const record = obj;
69
+ return record.asset?._ref === oldId ? { [path ? `${path}.asset` : "asset"]: { _type: "reference", _ref: newId } } : Object.keys(record).filter((k) => !k.startsWith("_")).reduce(
70
+ (acc, key) => {
71
+ const childPath = path ? `${path}.${key}` : key;
72
+ return Object.assign(
73
+ acc,
74
+ buildReplacementPatch(record[key], oldId, newId, childPath)
75
+ );
76
+ },
77
+ {}
78
+ );
79
+ }
80
+ const validateImageSize = async (value, context) => {
81
+ if (!value?.asset?._ref) return !0;
82
+ const asset = await context.getClient({ apiVersion: "2025-02-19" }).fetch(
83
+ '*[_id == $id][0]{ size, "width": metadata.dimensions.width, mimeType }',
84
+ { id: value.asset._ref }
85
+ );
86
+ if (!asset) return !0;
87
+ const allowedMimeTypes = exports.IMAGE_ACCEPT.split(",").map((s) => s.trim());
88
+ if (asset.mimeType && !allowedMimeTypes.includes(asset.mimeType))
89
+ return `File type "${asset.mimeType}" is not allowed. Accepted types: ${allowedMimeTypes.join(", ")}`;
90
+ if (asset.size && asset.size > exports.IMAGE_MAX_SIZE) {
91
+ const sizeMB = (asset.size / 1048576).toFixed(1), maxMB = (exports.IMAGE_MAX_SIZE / (1024 * 1024)).toFixed(0);
92
+ return `Image size (${sizeMB}MB) exceeds the maximum of ${maxMB}MB`;
93
+ }
94
+ return asset.width && asset.width > exports.IMAGE_MAX_WIDTH ? `Image width (${asset.width}px) exceeds the maximum of ${exports.IMAGE_MAX_WIDTH}px` : !0;
95
+ }, MAX_SIZE_MB$1 = exports.IMAGE_MAX_SIZE / 1024 / 1024, VIOLATION_LABELS = {
96
+ format: "TIFF \u2192 WebP",
97
+ width: `> ${exports.IMAGE_MAX_WIDTH}px`,
98
+ size: `> ${MAX_SIZE_MB$1} MB`
99
+ };
100
+ function formatViolationLabel(settings) {
101
+ const parts = [];
102
+ return parts.push(settings.tiffToJpg ? "TIFF \u2192 JPG" : "TIFF \u2192 WebP"), settings.pngToWebp && parts.push("PNG \u2192 WebP"), parts.join(", ");
103
+ }
104
+ function ViolationBadge({
105
+ type,
106
+ settings
107
+ }) {
108
+ const label = type === "format" ? formatViolationLabel(settings) : VIOLATION_LABELS[type];
109
+ return /* @__PURE__ */ jsxRuntime.jsx(ui.Badge, { tone: "caution", size: 1, children: label });
110
+ }
111
+ function statusTone(status) {
112
+ return {
113
+ done: "positive",
114
+ error: "critical",
115
+ processing: "primary",
116
+ idle: "default"
117
+ }[status];
118
+ }
119
+ function AssetCard({
120
+ asset,
121
+ onProcess,
122
+ settings
123
+ }) {
124
+ const isDone = asset.status === "done" && asset.newUrl, thumbUrl = isDone ? asset.newUrl : asset.url, sizeReduction = isDone && asset.newSize != null ? Math.round((1 - asset.newSize / asset.size) * 100) : null;
125
+ return /* @__PURE__ */ jsxRuntime.jsx(
126
+ ui.Card,
127
+ {
128
+ tone: statusTone(asset.status),
129
+ style: { width: "calc(100vw - 1.25rem * 2)" },
130
+ children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, { gap: 3, align: "center", children: [
131
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Box, { style: { width: 64, height: 64, flexShrink: 0 }, children: /* @__PURE__ */ jsxRuntime.jsx(
132
+ "img",
133
+ {
134
+ src: `${thumbUrl}?w=128&h=128&fit=crop&auto=format`,
135
+ alt: "",
136
+ loading: "lazy",
137
+ style: {
138
+ width: 64,
139
+ height: 64,
140
+ objectFit: "cover",
141
+ borderRadius: 4
142
+ }
143
+ }
144
+ ) }),
145
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Stack, { space: 2, style: { flex: 1, minWidth: 0 }, children: [
146
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Box, { style: { width: "100%", minWidth: 0 }, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 1, weight: "semibold", children: isDone ? asset.newFilename || asset.originalFilename || asset._id : asset.originalFilename || asset._id }) }),
147
+ isDone ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
148
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { size: 1, muted: !0, style: { wordBreak: "break-word" }, children: [
149
+ (asset.size / 1024 / 1024).toFixed(1),
150
+ " MB \u2192",
151
+ " ",
152
+ (asset.newSize / 1024 / 1024).toFixed(1),
153
+ " MB",
154
+ sizeReduction !== null && sizeReduction > 0 ? ` (\u2212${sizeReduction}%)` : "",
155
+ " ",
156
+ "\u2014 ",
157
+ asset.newWidth,
158
+ "px wide"
159
+ ] }),
160
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Flex, { gap: 2, wrap: "wrap", children: asset.newWidth != null && asset.newWidth < asset.width && /* @__PURE__ */ jsxRuntime.jsxs(ui.Badge, { tone: "positive", size: 1, children: [
161
+ asset.width,
162
+ "px \u2192 ",
163
+ asset.newWidth,
164
+ "px"
165
+ ] }) })
166
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
167
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Flex, { gap: 2, wrap: "wrap", children: asset.violations.map((v) => /* @__PURE__ */ jsxRuntime.jsx(ViolationBadge, { type: v, settings }, v)) }),
168
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { size: 1, muted: !0, style: { wordBreak: "break-word" }, children: [
169
+ (asset.size / 1024 / 1024).toFixed(1),
170
+ " MB \u2014 ",
171
+ asset.width,
172
+ "px wide"
173
+ ] })
174
+ ] }),
175
+ asset.status === "error" && /* @__PURE__ */ jsxRuntime.jsx(
176
+ ui.Text,
177
+ {
178
+ size: 1,
179
+ style: {
180
+ color: "var(--card-badge-critical-dot-color)",
181
+ wordBreak: "break-word"
182
+ },
183
+ children: asset.error
184
+ }
185
+ )
186
+ ] }),
187
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Box, { style: { flexShrink: 0 }, children: [
188
+ asset.status === "idle" && /* @__PURE__ */ jsxRuntime.jsx(
189
+ ui.Button,
190
+ {
191
+ text: "Process",
192
+ mode: "ghost",
193
+ tone: "primary",
194
+ onClick: () => onProcess(asset)
195
+ }
196
+ ),
197
+ asset.status === "processing" && /* @__PURE__ */ jsxRuntime.jsx(ui.Spinner, {}),
198
+ asset.status === "done" && /* @__PURE__ */ jsxRuntime.jsx(ui.Badge, { tone: "positive", children: "Done" }),
199
+ asset.status === "error" && /* @__PURE__ */ jsxRuntime.jsx(
200
+ ui.Button,
201
+ {
202
+ text: "Retry",
203
+ mode: "ghost",
204
+ tone: "critical",
205
+ onClick: () => onProcess(asset)
206
+ }
207
+ )
208
+ ] })
209
+ ] })
210
+ },
211
+ asset._id
212
+ );
213
+ }
214
+ const MAX_SIZE_MB = exports.IMAGE_MAX_SIZE / 1024 / 1024, KV_SETTINGS_KEY = "image-resizer-settings";
215
+ function loadSettings() {
216
+ try {
217
+ const raw = localStorage.getItem(KV_SETTINGS_KEY);
218
+ if (raw) {
219
+ const parsed = JSON.parse(raw);
220
+ return {
221
+ pngToWebp: typeof parsed.pngToWebp == "boolean" ? parsed.pngToWebp : DEFAULT_SETTINGS.pngToWebp,
222
+ tiffToJpg: typeof parsed.tiffToJpg == "boolean" ? parsed.tiffToJpg : DEFAULT_SETTINGS.tiffToJpg
223
+ };
224
+ }
225
+ } catch {
226
+ }
227
+ return DEFAULT_SETTINGS;
228
+ }
229
+ function ImageResizerView() {
230
+ const client = sanity.useClient({ apiVersion: "2025-02-19" }), [assets, setAssets] = react.useState([]), [loading, setLoading] = react.useState(!0), [processingAll, setProcessingAll] = react.useState(!1), [settings, setSettings] = react.useState(loadSettings), [showSettings, setShowSettings] = react.useState(!1), updateSettings = react.useCallback(
231
+ (updater) => {
232
+ setSettings((prev) => {
233
+ const next = updater(prev);
234
+ return localStorage.setItem(KV_SETTINGS_KEY, JSON.stringify(next)), next;
235
+ });
236
+ },
237
+ []
238
+ ), assetsRef = react.useRef(assets);
239
+ react.useEffect(() => {
240
+ assetsRef.current = assets;
241
+ }, [assets]);
242
+ const fetchAssets = react.useCallback(async () => {
243
+ setLoading(!0);
244
+ try {
245
+ const raw = await client.fetch(
246
+ `*[_type == "sanity.imageAsset" && (
247
+ mimeType == "image/tiff" ||
248
+ metadata.dimensions.width > ${exports.IMAGE_MAX_WIDTH} ||
249
+ size > ${exports.IMAGE_MAX_SIZE}
250
+ )][] {
251
+ _id, url, originalFilename, mimeType, size,
252
+ "width": metadata.dimensions.width
253
+ }`
254
+ );
255
+ setAssets(
256
+ raw.map((a) => ({
257
+ ...a,
258
+ violations: getViolations(a, settings),
259
+ status: "idle"
260
+ }))
261
+ );
262
+ } finally {
263
+ setLoading(!1);
264
+ }
265
+ }, [client, settings]);
266
+ react.useEffect(() => {
267
+ fetchAssets();
268
+ }, [fetchAssets]);
269
+ const updateAsset = react.useCallback(
270
+ (id, patch) => setAssets(
271
+ (prev) => prev.map((a) => a._id === id ? { ...a, ...patch } : a)
272
+ ),
273
+ []
274
+ ), processAsset = react.useCallback(
275
+ async (asset) => {
276
+ updateAsset(asset._id, { status: "processing", error: void 0 });
277
+ try {
278
+ const { blob, outFormat } = await processImage(
279
+ asset.url,
280
+ asset.mimeType,
281
+ asset.width,
282
+ settings
283
+ );
284
+ if (blob.size >= asset.size) {
285
+ updateAsset(asset._id, {
286
+ status: "error",
287
+ error: `Skipped: optimised file (${(blob.size / 1024 / 1024).toFixed(1)} MB) is not smaller than original (${(asset.size / 1024 / 1024).toFixed(1)} MB)`
288
+ });
289
+ return;
290
+ }
291
+ const baseName = asset.originalFilename?.replace(/\.[^.]+$/, "") || "image", newAsset = await client.assets.upload("image", blob, {
292
+ filename: `${baseName}.${outFormat}`,
293
+ contentType: `image/${outFormat}`
294
+ }), refs = await client.fetch(
295
+ "*[references($id)]{ _id }",
296
+ { id: asset._id }
297
+ );
298
+ for (const { _id } of refs) {
299
+ const doc = await client.getDocument(_id);
300
+ if (!doc) continue;
301
+ const patch = buildReplacementPatch(doc, asset._id, newAsset._id);
302
+ Object.keys(patch).length > 0 && await client.patch(_id).set(patch).commit();
303
+ }
304
+ await client.delete(asset._id);
305
+ const newMeta = await client.fetch(
306
+ '*[_id == $id][0]{ url, size, originalFilename, "width": metadata.dimensions.width }',
307
+ { id: newAsset._id }
308
+ );
309
+ updateAsset(asset._id, {
310
+ status: "done",
311
+ newUrl: newMeta?.url ?? newAsset.url,
312
+ newSize: newMeta?.size ?? blob.size,
313
+ newWidth: newMeta?.width ?? Math.min(asset.width, exports.IMAGE_MAX_WIDTH),
314
+ newFilename: newMeta?.originalFilename ?? `${baseName}.${outFormat}`
315
+ });
316
+ } catch (err) {
317
+ const message = err instanceof Error ? err.message : String(err);
318
+ updateAsset(asset._id, { status: "error", error: message });
319
+ }
320
+ },
321
+ [client, updateAsset, settings]
322
+ ), processAll = react.useCallback(async () => {
323
+ setProcessingAll(!0);
324
+ const pending = assetsRef.current.filter(
325
+ (a) => a.status === "idle" || a.status === "error"
326
+ );
327
+ let idx = 0;
328
+ const next = async () => {
329
+ for (; idx < pending.length; ) {
330
+ const asset = pending[idx++];
331
+ await processAsset(asset);
332
+ }
333
+ };
334
+ await Promise.all(Array.from({ length: CONCURRENCY }, () => next())), setProcessingAll(!1);
335
+ }, [processAsset]), counts = react.useMemo(
336
+ () => ({
337
+ pending: assets.filter((a) => a.status === "idle").length,
338
+ processing: assets.filter((a) => a.status === "processing").length,
339
+ done: assets.filter((a) => a.status === "done").length,
340
+ error: assets.filter((a) => a.status === "error").length
341
+ }),
342
+ [assets]
343
+ );
344
+ return /* @__PURE__ */ jsxRuntime.jsxs(
345
+ ui.Container,
346
+ {
347
+ width: 4,
348
+ padding: 4,
349
+ style: { width: "calc(100vw - 1.25rem * 2)" },
350
+ children: [
351
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Stack, { space: 5, children: [
352
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, { align: "flex-start", justify: "space-between", gap: 4, wrap: "wrap", children: [
353
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Stack, { space: 2, style: { flex: 1, minWidth: 0 }, children: [
354
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { size: 2, children: "Image Optimiser" }),
355
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { size: 1, muted: !0, style: { wordBreak: "break-word" }, children: [
356
+ "Converts TIFF images to WebP. Resizes/compresses all images to fit within ",
357
+ exports.IMAGE_MAX_WIDTH,
358
+ "px / ",
359
+ MAX_SIZE_MB,
360
+ " MB."
361
+ ] })
362
+ ] }),
363
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, { gap: 2, align: "center", wrap: "wrap", style: { flexShrink: 0 }, children: [
364
+ /* @__PURE__ */ jsxRuntime.jsx(
365
+ ui.Button,
366
+ {
367
+ icon: icons.CogIcon,
368
+ mode: "ghost",
369
+ onClick: () => setShowSettings(!0),
370
+ disabled: processingAll
371
+ }
372
+ ),
373
+ /* @__PURE__ */ jsxRuntime.jsx(
374
+ ui.Button,
375
+ {
376
+ text: "Refresh",
377
+ mode: "ghost",
378
+ onClick: fetchAssets,
379
+ disabled: loading || processingAll
380
+ }
381
+ ),
382
+ counts.pending > 0 && /* @__PURE__ */ jsxRuntime.jsx(
383
+ ui.Button,
384
+ {
385
+ text: processingAll ? "Processing\u2026" : `Process All (${counts.pending})`,
386
+ tone: "primary",
387
+ onClick: processAll,
388
+ disabled: processingAll || loading,
389
+ icon: processingAll ? ui.Spinner : void 0
390
+ }
391
+ )
392
+ ] })
393
+ ] }),
394
+ !loading && assets.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, { gap: 3, wrap: "wrap", children: [
395
+ counts.pending > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ui.Badge, { tone: "caution", children: [
396
+ counts.pending,
397
+ " pending"
398
+ ] }),
399
+ counts.processing > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ui.Badge, { tone: "primary", children: [
400
+ counts.processing,
401
+ " processing"
402
+ ] }),
403
+ counts.done > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ui.Badge, { tone: "positive", children: [
404
+ counts.done,
405
+ " done"
406
+ ] }),
407
+ counts.error > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ui.Badge, { tone: "critical", children: [
408
+ counts.error,
409
+ " failed"
410
+ ] })
411
+ ] }),
412
+ loading ? /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, { padding: 6, justify: "center", align: "center", gap: 3, children: [
413
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Spinner, {}),
414
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { muted: !0, children: "Scanning assets\u2026" })
415
+ ] }) : assets.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(ui.Card, { padding: 5, radius: 2, tone: "positive", border: !0, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { align: "center", children: "All images meet the requirements." }) }) : /* @__PURE__ */ jsxRuntime.jsx(ui.Stack, { space: 2, children: assets.map((asset) => /* @__PURE__ */ jsxRuntime.jsx(
416
+ AssetCard,
417
+ {
418
+ asset,
419
+ onProcess: processAsset,
420
+ settings
421
+ },
422
+ asset._id
423
+ )) })
424
+ ] }),
425
+ showSettings && /* @__PURE__ */ jsxRuntime.jsx(
426
+ ui.Dialog,
427
+ {
428
+ id: "image-optimiser-settings",
429
+ header: "Conversion Settings",
430
+ onClose: () => setShowSettings(!1),
431
+ width: 1,
432
+ children: /* @__PURE__ */ jsxRuntime.jsx(ui.Box, { padding: 4, children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Stack, { space: 4, children: [
433
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, { align: "center", gap: 3, children: [
434
+ /* @__PURE__ */ jsxRuntime.jsx(
435
+ ui.Switch,
436
+ {
437
+ id: "png-to-webp",
438
+ checked: settings.pngToWebp,
439
+ onChange: (e) => {
440
+ const checked = e.currentTarget.checked;
441
+ updateSettings((s) => ({ ...s, pngToWebp: checked }));
442
+ }
443
+ }
444
+ ),
445
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "png-to-webp", style: { cursor: "pointer" }, children: "Convert PNG \u2192 WebP" })
446
+ ] }),
447
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, { align: "center", gap: 3, children: [
448
+ /* @__PURE__ */ jsxRuntime.jsx(
449
+ ui.Switch,
450
+ {
451
+ id: "tiff-to-jpg",
452
+ checked: settings.tiffToJpg,
453
+ onChange: (e) => {
454
+ const checked = e.currentTarget.checked;
455
+ updateSettings((s) => ({ ...s, tiffToJpg: checked }));
456
+ }
457
+ }
458
+ ),
459
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "tiff-to-jpg", style: { cursor: "pointer" }, children: "Convert TIFF \u2192 JPG (instead of WebP)" })
460
+ ] }),
461
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 1, muted: !0, children: "Changes apply on next Refresh." })
462
+ ] }) })
463
+ }
464
+ )
465
+ ]
466
+ }
467
+ );
468
+ }
469
+ const imageResizerPlugin = sanity.definePlugin(
470
+ (options) => (applyConfig(options ?? void 0), {
471
+ name: "sanity-plugin-image-resizer",
472
+ tools: (prev) => [
473
+ ...prev,
474
+ {
475
+ name: "image-resizer",
476
+ title: "Image Optimiser",
477
+ component: ImageResizerView
478
+ }
479
+ ],
480
+ i18n: {
481
+ bundles: [imageResizerUsEnglishLocaleBundle]
482
+ }
483
+ })
484
+ );
485
+ exports.imageResizerPlugin = imageResizerPlugin;
486
+ exports.validateImageSize = validateImageSize;
487
+ //# sourceMappingURL=index.js.map