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 +21 -0
- package/README.md +91 -0
- package/dist/_chunks-cjs/resources.js +4 -0
- package/dist/_chunks-cjs/resources.js.map +1 -0
- package/dist/_chunks-es/resources.mjs +5 -0
- package/dist/_chunks-es/resources.mjs.map +1 -0
- package/dist/index.d.mts +31 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +487 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +490 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +85 -0
- package/sanity.json +8 -0
- package/src/config.ts +61 -0
- package/src/constants.ts +1 -0
- package/src/helpers.ts +232 -0
- package/src/i18n/index.ts +9 -0
- package/src/i18n/resources.ts +1 -0
- package/src/index.ts +9 -0
- package/src/plugin.tsx +52 -0
- package/src/tool/ImageResizer.tsx +389 -0
- package/src/tool/components/AssetCard.tsx +185 -0
- package/v2-incompatible.js +11 -0
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 @@
|
|
|
1
|
+
{"version":3,"file":"resources.js","sources":["../../src/i18n/resources.ts"],"sourcesContent":["export default {}\n"],"names":[],"mappings":";AAAA,IAAA,YAAe,CAAA;;"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resources.mjs","sources":["../../src/i18n/resources.ts"],"sourcesContent":["export default {}\n"],"names":[],"mappings":"AAAA,IAAA,YAAe,CAAA;"}
|
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|