sanity-plugin-alt-text-generator 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) 2026 Bryam Loaiza
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,89 @@
1
+ # sanity-plugin-alt-text-generator
2
+
3
+ Generate alt text for your images inside Sanity CMS. You can generate alt text one image at a time, or in bulk for all your assets.
4
+
5
+ [Get a free API key →](https://alttextgeneratorai.com/dashboard)
6
+
7
+ ## Features
8
+
9
+ - **Per-field "Generate Alt Text" button** on any image field with an `alt` subfield
10
+ - **Bulk dashboard** — a dedicated "Alt Text Generator" tool in the Studio nav that scans your entire dataset for image fields, lets you filter by missing/all, generate one at a time or in bulk, review and edit before saving, and save every change in one transaction
11
+ - **In-Studio Settings panel** — paste your API key directly in the Studio, no code or redeploy required; shows your remaining credit balance
12
+ - Automatically finds every image field with an `alt` subfield across your schema — no manual field configuration needed
13
+
14
+ ## Installation
15
+
16
+ ```sh
17
+ npm install sanity-plugin-alt-text-generator
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ Add the plugin in `sanity.config.ts` (or `.js`):
23
+
24
+ ```ts
25
+ import {defineConfig} from 'sanity'
26
+ import {altTextGenerator} from 'sanity-plugin-alt-text-generator'
27
+
28
+ export default defineConfig({
29
+ // ...
30
+ plugins: [altTextGenerator()],
31
+ })
32
+ ```
33
+
34
+ Open the **Alt Text Generator** tool in the Studio nav and paste your key into its Settings panel ([get a free one here](https://alttextgeneratorai.com/dashboard)). You can also pass a default key in code for local development/CI.
35
+
36
+ ```ts
37
+ altTextGenerator({apiKey: process.env.SANITY_STUDIO_ALT_TEXT_API_KEY})
38
+ ```
39
+
40
+ ### Wiring up the per-field button
41
+
42
+ Any image field with an `alt` subfield is automatically picked up by the bulk dashboard. To also get a one-click "Generate Alt Text" button on that field, wire it in one of two ways:
43
+
44
+ **On the `alt` string field itself** (recommended — the button renders right under Alternative Text, above any other fields like Caption):
45
+
46
+ ```ts
47
+ import {defineField, defineType} from 'sanity'
48
+ import {AltTextField} from 'sanity-plugin-alt-text-generator'
49
+
50
+ defineType({
51
+ name: 'imageWithAlt',
52
+ type: 'image',
53
+ fields: [
54
+ defineField({
55
+ name: 'alt',
56
+ title: 'Alternative Text',
57
+ type: 'string',
58
+ components: {input: AltTextField},
59
+ }),
60
+ defineField({name: 'caption', type: 'string'}),
61
+ ],
62
+ })
63
+ ```
64
+
65
+ **On the whole image field** (button renders below all of the image's fields at once):
66
+
67
+ ```ts
68
+ import {AltTextImageInput} from 'sanity-plugin-alt-text-generator'
69
+
70
+ defineField({
71
+ name: 'mainImage',
72
+ type: 'image',
73
+ components: {input: AltTextImageInput},
74
+ })
75
+ ```
76
+
77
+ Either way, the image type needs an `alt` string field for the bulk dashboard to find it and for either component to know where to write the result.
78
+
79
+ ## License
80
+
81
+ [MIT](LICENSE) © Bryam Loaiza
82
+
83
+ ## Develop & test
84
+
85
+ This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugins/tree/main/packages/@sanity/plugin-kit)
86
+ with default configuration for build & watch scripts.
87
+
88
+ See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugins/tree/main/packages/@sanity/plugin-kit#testing-a-plugin-in-sanity-studio)
89
+ on how to run this plugin with hotreload in the studio.
@@ -0,0 +1,86 @@
1
+ import { ImageInputProps, StringInputProps } from "sanity";
2
+ interface AltTextGeneratorConfig {
3
+ /**
4
+ * Optional default API key from your Alt Text Generator account
5
+ * (alttextgeneratorai.com). Not required — merchants can instead (or also)
6
+ * paste their key into the plugin's own Settings panel in the Studio, which
7
+ * takes priority when both are set. Passing one here is mainly useful for
8
+ * local development or CI.
9
+ */
10
+ apiKey?: string;
11
+ /** Override the generation endpoint — mainly useful for local development. */
12
+ apiEndpoint?: string;
13
+ /** Default language for generated alt text, e.g. "english", "spanish". */
14
+ language?: string;
15
+ }
16
+ /**
17
+ * Drop-in replacement for the default image input: renders the normal image
18
+ * field (upload, crop, hotspot — via `renderDefault`) plus a "Generate Alt
19
+ * Text" button that sends the uploaded asset's CDN URL to the Alt Text
20
+ * Generator API and writes the result into the image's `alt` field.
21
+ */
22
+ export declare function AltTextImageInput(props: ImageInputProps): import("react").JSX.Element;
23
+ /**
24
+ * Attaches to the `alt` STRING field itself (not the whole image type), so
25
+ * the "Generate Alt Text" button renders directly under the Alternative Text
26
+ * input instead of below every other field on the image (e.g. Caption).
27
+ *
28
+ * Reads the sibling image value via useFormValue since a field-level input
29
+ * component only gets its own value/path, not the parent object's.
30
+ */
31
+ export declare function AltTextField(props: StringInputProps): import("react").JSX.Element;
32
+ /**
33
+ * The bulk companion to AltTextImageInput: scans every document type in the
34
+ * schema for image fields that have an `alt` subfield (see schemaScan.ts),
35
+ * lists every image found across the dataset, and lets the user review,
36
+ * generate, and save alt text for many images in one pass.
37
+ */
38
+ export declare function AltTextDashboard(): import("react").JSX.Element;
39
+ /**
40
+ * Usage in `sanity.config.ts`:
41
+ *
42
+ * ```ts
43
+ * import {defineConfig} from 'sanity'
44
+ * import {altTextGenerator} from 'sanity-plugin-alt-text-generator'
45
+ *
46
+ * export default defineConfig({
47
+ * // ...
48
+ * plugins: [altTextGenerator()],
49
+ * })
50
+ * ```
51
+ *
52
+ * No API key required at setup time — open the "Alt Text Generator" tool in
53
+ * the Studio nav and paste your key into its Settings panel (get a free one
54
+ * at alttextgeneratorai.com). You can also pass a default `apiKey` here for
55
+ * local development/CI; the Settings-panel key takes priority once saved.
56
+ *
57
+ * Then wire it into your schema in one of two ways:
58
+ *
59
+ * 1. On the `alt` string field itself (recommended — puts the "Generate Alt
60
+ * Text" button right under Alternative Text, above any other fields like
61
+ * Caption):
62
+ *
63
+ * ```ts
64
+ * defineField({
65
+ * name: 'alt',
66
+ * type: 'string',
67
+ * components: {input: AltTextField},
68
+ * })
69
+ * ```
70
+ *
71
+ * 2. On the whole image field (button renders below all of the image's
72
+ * fields at once):
73
+ *
74
+ * ```ts
75
+ * defineField({
76
+ * name: 'mainImage',
77
+ * type: 'image',
78
+ * components: {input: AltTextImageInput},
79
+ * })
80
+ * ```
81
+ *
82
+ * @public
83
+ */
84
+ export declare const altTextGenerator: import("sanity").Plugin<void | AltTextGeneratorConfig>;
85
+ export type { AltTextGeneratorConfig };
86
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,673 @@
1
+ import { definePlugin, set, useClient, useFormValue, useSchema } from "sanity";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Box, Button, Card, Checkbox, Container, Flex, Heading, Spinner, Stack, Text, TextArea, TextInput } from "@sanity/ui";
4
+ import imageUrlBuilder from "@sanity/image-url";
5
+ const AltTextGeneratorContext = createContext(null);
6
+ function useAltTextGeneratorConfig() {
7
+ let ctx = useContext(AltTextGeneratorContext);
8
+ if (!ctx) throw Error("useAltTextGeneratorConfig must be used within the alt-text-generator plugin. Did you forget to add altTextGenerator() to your sanity.config plugins array?");
9
+ return ctx;
10
+ }
11
+ /**
12
+ * A single, well-known document this plugin owns to store its own settings
13
+ * (currently just the API key). Not registered in the consumer's schema —
14
+ * Sanity's document store doesn't require a document's `_type` to be part of
15
+ * the Studio's schema, so this stays entirely internal to the plugin and
16
+ * never shows up in Content/search for the merchant.
17
+ */
18
+ const SETTINGS_DOC_ID = "sanity-plugin-alt-text-generator.settings", API_VERSION$5 = "2024-01-01";
19
+ async function getSettingsApiKey(client) {
20
+ return await client.withConfig({ apiVersion: API_VERSION$5 }).fetch("*[_id == $id][0].apiKey", { id: "sanity-plugin-alt-text-generator.settings" }) || void 0;
21
+ }
22
+ async function saveSettingsApiKey(client, apiKey) {
23
+ await client.withConfig({ apiVersion: API_VERSION$5 }).transaction().createIfNotExists({
24
+ _id: SETTINGS_DOC_ID,
25
+ _type: "sanityPluginAltTextGeneratorSettings"
26
+ }).patch(SETTINGS_DOC_ID, (p) => p.set({ apiKey })).commit();
27
+ }
28
+ /**
29
+ * Injects the plugin's config into React context for the whole Studio tree,
30
+ * via the `studio.components.layout` slot — this is how a plugin makes its
31
+ * config reachable from an input component deep in a document form, without
32
+ * the merchant having to repeat the API key on every field.
33
+ *
34
+ * The API key can come from two places: the static `apiKey` passed to
35
+ * `altTextGenerator({...})` in sanity.config.ts, and/or the key saved through
36
+ * the plugin's own Settings panel (stored in a document — see
37
+ * settingsStore.ts). The Settings-panel key takes priority once loaded, so a
38
+ * merchant can paste their key into the Studio UI without touching code.
39
+ */
40
+ function createAltTextGeneratorProvider(staticConfig) {
41
+ return function AltTextGeneratorProvider(props) {
42
+ let client = useClient({ apiVersion: "2024-01-01" }), [liveApiKey, setLiveApiKey] = useState(void 0);
43
+ useEffect(() => {
44
+ let cancelled = !1;
45
+ return getSettingsApiKey(client).then((key) => {
46
+ cancelled || setLiveApiKey(key);
47
+ }).catch(() => {}), () => {
48
+ cancelled = !0;
49
+ };
50
+ }, [client]);
51
+ let value = useMemo(() => ({
52
+ apiKey: liveApiKey || staticConfig.apiKey || "",
53
+ apiEndpoint: staticConfig.apiEndpoint,
54
+ language: staticConfig.language,
55
+ updateApiKey: setLiveApiKey
56
+ }), [liveApiKey, staticConfig]);
57
+ return <AltTextGeneratorContext.Provider value={value}>
58
+ {props.renderDefault(props)}
59
+ </AltTextGeneratorContext.Provider>;
60
+ };
61
+ }
62
+ function chainHasName(type, name) {
63
+ let t = type;
64
+ for (; t;) {
65
+ if (t.name === name) return !0;
66
+ t = t.type;
67
+ }
68
+ return !1;
69
+ }
70
+ function hasAltSubfield(imageType) {
71
+ return (imageType.fields || []).some((f) => f.name === "alt");
72
+ }
73
+ function walkFields(type, path, documentType, out, seen, depth) {
74
+ let fields = type.fields;
75
+ if (fields) for (let field of fields) {
76
+ let fieldPath = [...path, field.name], fieldType = field.type;
77
+ if (chainHasName(fieldType, "image")) {
78
+ if (hasAltSubfield(fieldType)) {
79
+ let key = `${documentType}:${fieldPath.join(".")}`;
80
+ seen.has(key) || (seen.add(key), out.push({
81
+ documentType,
82
+ fieldPath
83
+ }));
84
+ }
85
+ continue;
86
+ }
87
+ fieldType.jsonType === "object" && depth < 2 && walkFields(fieldType, fieldPath, documentType, out, seen, depth + 1);
88
+ }
89
+ }
90
+ /**
91
+ * Walks every document type in the (fully merged, runtime) schema and finds
92
+ * every image field that has an `alt` subfield — the convention this plugin's
93
+ * own AltTextImageInput relies on, and the one most real-world Sanity schemas
94
+ * already use for accessible images either way.
95
+ */
96
+ function findAltTextFields(schema) {
97
+ let results = [], seen = /* @__PURE__ */ new Set();
98
+ for (let typeName of schema.getTypeNames()) {
99
+ let type = schema.get(typeName);
100
+ type && chainHasName(type, "document") && walkFields(type, [], typeName, results, seen, 0);
101
+ }
102
+ return results;
103
+ }
104
+ function fieldAlias(fieldPath) {
105
+ return "f_" + fieldPath.join("_");
106
+ }
107
+ function groqPath(fieldPath) {
108
+ return fieldPath.join(".");
109
+ }
110
+ /**
111
+ * One GROQ query per document type (fields are grouped by documentType first),
112
+ * projecting every discovered image field on that type plus a best-effort title.
113
+ */
114
+ function buildQuery(documentType, fields) {
115
+ return `*[_type == "${documentType}"]{
116
+ _id,
117
+ _type,
118
+ "documentTitle": coalesce(title, name, heading, label, _id),
119
+ ${fields.map((f) => `"${fieldAlias(f.fieldPath)}": ${groqPath(f.fieldPath)}{alt, "url": asset->url}`).join(",\n ")}
120
+ }`;
121
+ }
122
+ async function fetchAltTextRows(client, targets) {
123
+ let byType = /* @__PURE__ */ new Map();
124
+ for (let t of targets) {
125
+ let list = byType.get(t.documentType) || [];
126
+ list.push(t), byType.set(t.documentType, list);
127
+ }
128
+ let rows = [];
129
+ for (let [documentType, fields] of byType) {
130
+ let query = buildQuery(documentType, fields), docs = await client.fetch(query);
131
+ for (let doc of docs) for (let f of fields) {
132
+ let value = doc[fieldAlias(f.fieldPath)];
133
+ value && value.url && rows.push({
134
+ key: `${doc._id}:${f.fieldPath.join(".")}`,
135
+ documentId: doc._id,
136
+ documentType,
137
+ documentTitle: doc.documentTitle || doc._id,
138
+ fieldPath: f.fieldPath,
139
+ fieldLabel: f.fieldPath.join(" › "),
140
+ imageUrl: value.url,
141
+ alt: value.alt || ""
142
+ });
143
+ }
144
+ }
145
+ return rows;
146
+ }
147
+ /** Saves every update in one transaction — either all land or none do. */
148
+ async function saveAltTextUpdates(client, updates) {
149
+ if (!updates.length) return;
150
+ let tx = client.transaction();
151
+ for (let u of updates) {
152
+ let patchKey = `${groqPath(u.fieldPath)}.alt`;
153
+ tx = tx.patch(u.documentId, (p) => p.set({ [patchKey]: u.alt }));
154
+ }
155
+ await tx.commit();
156
+ }
157
+ function messageForStatus(status, body) {
158
+ return status === 401 ? {
159
+ message: "Invalid API key. Check it under the plugin config.",
160
+ tone: "critical"
161
+ } : status === 402 ? {
162
+ message: "No credits left on this API key.",
163
+ tone: "caution"
164
+ } : {
165
+ message: body || `Generation failed (${status}).`,
166
+ tone: "critical"
167
+ };
168
+ }
169
+ async function generateAltText(imageUrl, config) {
170
+ try {
171
+ let endpoint = config.apiEndpoint || "https://alttextgeneratorai.com/api/sanity", res = await fetch(endpoint, {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/json" },
174
+ body: JSON.stringify({
175
+ image: imageUrl,
176
+ wpkey: config.apiKey,
177
+ language: config.language || "english"
178
+ })
179
+ }), body = await res.text();
180
+ if (!res.ok) return {
181
+ ok: !1,
182
+ ...messageForStatus(res.status, body)
183
+ };
184
+ let creditsHeader = res.headers.get("X-Credits-Left");
185
+ return {
186
+ ok: !0,
187
+ altText: body.trim(),
188
+ creditsLeft: creditsHeader === null ? null : Number(creditsHeader)
189
+ };
190
+ } catch (e) {
191
+ return {
192
+ ok: !1,
193
+ message: e instanceof Error ? e.message : "Network error while generating alt text.",
194
+ tone: "critical"
195
+ };
196
+ }
197
+ }
198
+ /**
199
+ * The credits-check endpoint isn't Sanity-specific — it's the same
200
+ * `/api/verify` every plugin (Framer, WordPress, Magento...) already calls.
201
+ * Derived from the generation endpoint so a local-dev `apiEndpoint` override
202
+ * (e.g. http://localhost:3000/api/sanity) checks credits against the same
203
+ * local backend instead of production.
204
+ */
205
+ function verifyEndpointFor(config) {
206
+ return (config.apiEndpoint || "https://alttextgeneratorai.com/api/sanity").replace(/\/[^/]+$/, "/verify");
207
+ }
208
+ async function fetchCreditsRemaining(config, apiKey) {
209
+ if (!apiKey) return null;
210
+ try {
211
+ let res = await fetch(verifyEndpointFor(config), {
212
+ method: "POST",
213
+ headers: { "Content-Type": "application/json" },
214
+ body: JSON.stringify({ apiKey })
215
+ });
216
+ if (!res.ok) return null;
217
+ let data = await res.json();
218
+ return typeof data.freeRewritesLeft == "number" ? data.freeRewritesLeft : null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+ /**
224
+ * Lets a merchant paste their API key directly in the Studio, no code edit
225
+ * required — saved to a plugin-internal settings document (settingsStore.ts)
226
+ * and pushed straight into the shared context so every open field/tool picks
227
+ * it up immediately. Also shows the current credit balance for the active key.
228
+ */
229
+ function SettingsPanel() {
230
+ let client = useClient({ apiVersion: "2024-01-01" }), config = useAltTextGeneratorConfig(), [keyInput, setKeyInput] = useState(config.apiKey), [saving, setSaving] = useState(!1), [justSaved, setJustSaved] = useState(!1), [error, setError] = useState(null), [credits, setCredits] = useState(null), [checkingCredits, setCheckingCredits] = useState(!1);
231
+ useEffect(() => {
232
+ setKeyInput(config.apiKey);
233
+ }, [config.apiKey]);
234
+ let checkCredits = useCallback(async (apiKey) => {
235
+ if (!apiKey) {
236
+ setCredits(null);
237
+ return;
238
+ }
239
+ setCheckingCredits(!0);
240
+ let result = await fetchCreditsRemaining(config, apiKey);
241
+ setCredits(result), setCheckingCredits(!1);
242
+ }, [config]);
243
+ useEffect(() => {
244
+ checkCredits(config.apiKey);
245
+ }, [config.apiKey]);
246
+ let handleSave = useCallback(async () => {
247
+ setSaving(!0), setError(null), setJustSaved(!1);
248
+ try {
249
+ let trimmed = keyInput.trim();
250
+ await saveSettingsApiKey(client, trimmed), config.updateApiKey(trimmed), setJustSaved(!0), checkCredits(trimmed);
251
+ } catch (e) {
252
+ setError(e instanceof Error ? e.message : "Failed to save.");
253
+ } finally {
254
+ setSaving(!1);
255
+ }
256
+ }, [
257
+ client,
258
+ keyInput,
259
+ config,
260
+ checkCredits
261
+ ]);
262
+ return <Card padding={4} radius={2} border tone="transparent">
263
+ <Stack gap={3}>
264
+ <Text size={1} weight="medium">
265
+ API Key
266
+ </Text>
267
+ <Text size={1} muted>
268
+ Paste your Alt Text Generator API key below.{" "}
269
+ <a href={"https://alttextgeneratorai.com/dashboard"} target="_blank" rel="noreferrer">
270
+ Get a free API key →
271
+ </a>
272
+ </Text>
273
+ <Flex gap={2} align="center" wrap="wrap">
274
+ <Box flex={1} style={{ minWidth: 240 }}>
275
+ <TextInput value={keyInput} placeholder="Paste your API key" onChange={(e) => setKeyInput(e.currentTarget.value)} />
276
+ </Box>
277
+ <Button text={saving ? "Saving…" : "Save"} tone="primary" disabled={saving || keyInput.trim() === config.apiKey} onClick={handleSave} />
278
+ </Flex>
279
+ {justSaved && <Text size={1} style={{ color: "var(--card-positive-fg-color, #2a9d5c)" }}>
280
+ Saved.
281
+ </Text>}
282
+ {error && <Text size={1} style={{ color: "var(--card-critical-fg-color, #e02b27)" }}>
283
+ {error}
284
+ </Text>}
285
+ {!config.apiKey && <Text size={1} style={{ color: "var(--card-caution-fg-color, #a56a0d)" }}>
286
+ No API key configured yet — generation will fail until you add one.
287
+ </Text>}
288
+ {config.apiKey && <Text size={1} muted>
289
+ {checkingCredits ? "Checking credits…" : credits === null ? "Could not check credits for this key." : `${credits} credit${credits === 1 ? "" : "s"} remaining`}
290
+ </Text>}
291
+ </Stack>
292
+ </Card>;
293
+ }
294
+ /**
295
+ * The bulk companion to AltTextImageInput: scans every document type in the
296
+ * schema for image fields that have an `alt` subfield (see schemaScan.ts),
297
+ * lists every image found across the dataset, and lets the user review,
298
+ * generate, and save alt text for many images in one pass.
299
+ */
300
+ function AltTextDashboard() {
301
+ let schema = useSchema(), client = useClient({ apiVersion: "2024-01-01" }), config = useAltTextGeneratorConfig(), [scanning, setScanning] = useState(!1), [scanError, setScanError] = useState(null), [rows, setRows] = useState([]), [scannedTypes, setScannedTypes] = useState(0), [filter, setFilter] = useState("missing"), [overwrite, setOverwrite] = useState(!1), [selected, setSelected] = useState(/* @__PURE__ */ new Set()), [dirty, setDirty] = useState({}), [rowErrors, setRowErrors] = useState({}), [bulkRunning, setBulkRunning] = useState(!1), [progress, setProgress] = useState(""), [credits, setCredits] = useState(null), [saving, setSaving] = useState(!1), stopRequested = useRef(!1), targets = useMemo(() => findAltTextFields(schema), [schema]), handleScan = useCallback(async () => {
302
+ setScanning(!0), setScanError(null);
303
+ try {
304
+ let found = await fetchAltTextRows(client, targets);
305
+ setRows(found), setScannedTypes(new Set(targets.map((t) => t.documentType)).size), setDirty({}), setSelected(/* @__PURE__ */ new Set()), setRowErrors({});
306
+ } catch (e) {
307
+ setScanError(e instanceof Error ? e.message : "Scan failed.");
308
+ } finally {
309
+ setScanning(!1);
310
+ }
311
+ }, [client, targets]), currentAlt = useCallback((row) => row.key in dirty ? dirty[row.key] : row.alt, [dirty]), visibleRows = useMemo(() => rows.filter((row) => {
312
+ if (filter === "all") return !0;
313
+ let text = currentAlt(row);
314
+ return !text || text.trim() === "";
315
+ }), [
316
+ rows,
317
+ filter,
318
+ currentAlt
319
+ ]), isLocked = useCallback((row) => !overwrite && currentAlt(row).trim() !== "", [overwrite, currentAlt]), setRowText = useCallback((key, text) => {
320
+ setDirty((prev) => ({
321
+ ...prev,
322
+ [key]: text
323
+ }));
324
+ }, []), generateOne = useCallback(async (row) => {
325
+ if (setRowErrors((prev) => {
326
+ let next = { ...prev };
327
+ return delete next[row.key], next;
328
+ }), !row.imageUrl) return {
329
+ ok: !1,
330
+ message: "No image URL."
331
+ };
332
+ let result = await generateAltText(row.imageUrl, config);
333
+ return result.ok ? (setRowText(row.key, result.altText), result.creditsLeft !== null && setCredits(result.creditsLeft), { ok: !0 }) : (setRowErrors((prev) => ({
334
+ ...prev,
335
+ [row.key]: result.message
336
+ })), {
337
+ ok: !1,
338
+ message: result.message
339
+ });
340
+ }, [config, setRowText]), runBulk = useCallback(async (list) => {
341
+ setBulkRunning(!0), stopRequested.current = !1;
342
+ let done = 0, applied = 0, failed = 0, lastError = "";
343
+ for (let row of list) {
344
+ if (stopRequested.current) break;
345
+ let result = await generateOne(row);
346
+ if (done++, result.ok) applied++;
347
+ else if (failed++, lastError = result.message || "", lastError.toLowerCase().includes("credits")) {
348
+ setProgress(`Stopped: out of credits. ${applied} generated.`);
349
+ break;
350
+ }
351
+ setProgress(`${done} / ${list.length} · ${applied} generated` + (failed ? ` · ${failed} failed — last error: ${lastError}` : ""));
352
+ }
353
+ setBulkRunning(!1);
354
+ }, [generateOne]), handleGenerateSelected = useCallback(() => {
355
+ let list = visibleRows.filter((r) => selected.has(r.key) && !isLocked(r));
356
+ list.length && runBulk(list);
357
+ }, [
358
+ visibleRows,
359
+ selected,
360
+ isLocked,
361
+ runBulk
362
+ ]), handleGenerateAllMissing = useCallback(() => {
363
+ let list = rows.filter((r) => currentAlt(r).trim() === "");
364
+ list.length && runBulk(list);
365
+ }, [
366
+ rows,
367
+ currentAlt,
368
+ runBulk
369
+ ]), handleSave = useCallback(async () => {
370
+ let updates = Object.entries(dirty).map(([key, alt]) => {
371
+ let row = rows.find((r) => r.key === key);
372
+ return {
373
+ documentId: row.documentId,
374
+ fieldPath: row.fieldPath,
375
+ alt
376
+ };
377
+ });
378
+ if (updates.length) {
379
+ setSaving(!0);
380
+ try {
381
+ await saveAltTextUpdates(client, updates), setRows((prev) => prev.map((r) => r.key in dirty ? {
382
+ ...r,
383
+ alt: dirty[r.key]
384
+ } : r)), setDirty({}), setProgress(`Saved ${updates.length} image(s).`);
385
+ } catch (e) {
386
+ setProgress(`Save failed: ${e instanceof Error ? e.message : "unknown error"}`);
387
+ } finally {
388
+ setSaving(!1);
389
+ }
390
+ }
391
+ }, [
392
+ dirty,
393
+ rows,
394
+ client
395
+ ]), toggleSelectAll = useCallback((checked) => {
396
+ setSelected(checked ? new Set(visibleRows.filter((r) => !isLocked(r)).map((r) => r.key)) : /* @__PURE__ */ new Set());
397
+ }, [visibleRows, isLocked]), missingCount = rows.filter((r) => currentAlt(r).trim() === "").length;
398
+ return <Container width={4} padding={4}>
399
+ <Stack gap={4}>
400
+ <Heading size={2}>Alt Text Generator AI</Heading>
401
+
402
+ <SettingsPanel />
403
+
404
+ <Flex align="center" gap={3}>
405
+ <Button text={scanning ? "Scanning…" : "Scan Dataset"} tone="primary" disabled={scanning} onClick={handleScan} />
406
+ {rows.length > 0 && <Text size={1} muted>
407
+ {rows.length} image{rows.length === 1 ? "" : "s"} · {missingCount} missing alt text (scanned {scannedTypes}{" "}
408
+ document type{scannedTypes === 1 ? "" : "s"})
409
+ </Text>}
410
+ {scanning && <Spinner muted />}
411
+ </Flex>
412
+
413
+ {scanError && <Card tone="critical" padding={3} radius={2} border>
414
+ <Text size={1}>{scanError}</Text>
415
+ </Card>}
416
+
417
+ {targets.length === 0 && !scanning && <Card tone="caution" padding={3} radius={2} border>
418
+ <Text size={1}>
419
+ No image fields with an &quot;alt&quot; subfield were found in this Studio&apos;s schema. Add an{" "}
420
+ <code>alt</code> string field to an image type (see the README) for it to show up here.
421
+ </Text>
422
+ </Card>}
423
+
424
+ {rows.length > 0 && <Stack gap={4}>
425
+ <Flex align="center" gap={3}>
426
+ <Button text="Missing" mode={filter === "missing" ? "default" : "ghost"} tone={filter === "missing" ? "primary" : "default"} onClick={() => setFilter("missing")} />
427
+ <Button text="All" mode={filter === "all" ? "default" : "ghost"} tone={filter === "all" ? "primary" : "default"} onClick={() => setFilter("all")} />
428
+ <Flex align="center" gap={2}>
429
+ <Checkbox checked={overwrite} onChange={(e) => setOverwrite(e.currentTarget.checked)} id="atg-overwrite" />
430
+ <Text as="label" size={1} htmlFor="atg-overwrite">
431
+ Overwrite images that already have alt text
432
+ </Text>
433
+ </Flex>
434
+ </Flex>
435
+
436
+ <Flex align="center" gap={2}>
437
+ <Checkbox checked={visibleRows.length > 0 && visibleRows.every((r) => isLocked(r) || selected.has(r.key))} onChange={(e) => toggleSelectAll(e.currentTarget.checked)} id="atg-select-all" />
438
+ <Text as="label" size={1} htmlFor="atg-select-all">
439
+ Select all visible {selected.size > 0 && `(${selected.size} selected)`}
440
+ </Text>
441
+ </Flex>
442
+
443
+ <Card border radius={2} style={{
444
+ maxHeight: 520,
445
+ overflowY: "auto"
446
+ }}>
447
+ <Stack gap={0}>
448
+ {visibleRows.map((row) => {
449
+ let locked = isLocked(row), text = currentAlt(row), error = rowErrors[row.key];
450
+ return <Flex key={row.key} padding={3} gap={3} align="flex-start" style={{ borderBottom: "1px solid var(--card-border-color)" }}>
451
+ <Box paddingTop={1}>
452
+ <Checkbox checked={selected.has(row.key)} disabled={locked} onChange={(e) => {
453
+ let checked = e.currentTarget.checked;
454
+ setSelected((prev) => {
455
+ let next = new Set(prev);
456
+ return checked ? next.add(row.key) : next.delete(row.key), next;
457
+ });
458
+ }} />
459
+ </Box>
460
+ {row.imageUrl && <img src={`${row.imageUrl}?w=80&h=80&fit=crop`} width={56} height={56} style={{
461
+ objectFit: "cover",
462
+ borderRadius: 4,
463
+ flexShrink: 0
464
+ }} alt="" />}
465
+ <Box style={{
466
+ width: 220,
467
+ flexShrink: 0
468
+ }}>
469
+ <Text size={1} weight="medium">
470
+ {row.documentTitle}
471
+ </Text>
472
+ <Text size={1} muted>
473
+ {row.documentType} · {row.fieldLabel}
474
+ </Text>
475
+ </Box>
476
+ <Box flex={1}>
477
+ <Stack gap={2}>
478
+ <TextArea rows={2} value={text} disabled={locked} onChange={(e) => setRowText(row.key, e.currentTarget.value)} />
479
+ {error && <Text size={1} style={{ color: "var(--card-critical-fg-color, #e02b27)" }}>
480
+ {error}
481
+ </Text>}
482
+ </Stack>
483
+ </Box>
484
+ <Box paddingTop={1}>
485
+ <Button text="Generate" mode="ghost" fontSize={1} disabled={locked || bulkRunning} onClick={() => generateOne(row)} />
486
+ </Box>
487
+ </Flex>;
488
+ })}
489
+ </Stack>
490
+ </Card>
491
+
492
+ <Flex align="center" gap={3} wrap="wrap">
493
+ <Button text="Generate Selected" tone="primary" disabled={bulkRunning || selected.size === 0} onClick={handleGenerateSelected} />
494
+ <Button text="Generate All Missing" mode="ghost" disabled={bulkRunning || missingCount === 0} onClick={handleGenerateAllMissing} />
495
+ {bulkRunning && <Button text="Stop" mode="ghost" tone="critical" onClick={() => stopRequested.current = !0} />}
496
+ <Button text={saving ? "Saving…" : "Save Changes"} tone="positive" disabled={saving || Object.keys(dirty).length === 0} onClick={handleSave} />
497
+ {credits !== null && <Text size={1} muted>
498
+ ({credits} credits remaining)
499
+ </Text>}
500
+ </Flex>
501
+
502
+ {progress && <Text size={1} muted>
503
+ {progress}
504
+ </Text>}
505
+ </Stack>}
506
+ </Stack>
507
+ </Container>;
508
+ }
509
+ /**
510
+ * Drop-in replacement for the default image input: renders the normal image
511
+ * field (upload, crop, hotspot — via `renderDefault`) plus a "Generate Alt
512
+ * Text" button that sends the uploaded asset's CDN URL to the Alt Text
513
+ * Generator API and writes the result into the image's `alt` field.
514
+ */
515
+ function AltTextImageInput(props) {
516
+ let { value, onChange } = props, config = useAltTextGeneratorConfig(), client = useClient({ apiVersion: "2024-01-01" }), [loading, setLoading] = useState(!1), [error, setError] = useState(null), handleGenerate = useCallback(async () => {
517
+ if (value?.asset?._ref && config.apiKey) {
518
+ setLoading(!0), setError(null);
519
+ try {
520
+ let { projectId, dataset } = client.config();
521
+ if (!projectId || !dataset) throw Error("Could not determine the Sanity project/dataset for this Studio.");
522
+ let result = await generateAltText(imageUrlBuilder({
523
+ projectId,
524
+ dataset
525
+ }).image(value).url(), config);
526
+ if (!result.ok) {
527
+ setError(result);
528
+ return;
529
+ }
530
+ onChange(set(result.altText, ["alt"]));
531
+ } catch (e) {
532
+ setError({
533
+ ok: !1,
534
+ message: e instanceof Error ? e.message : "Network error while generating alt text.",
535
+ tone: "critical"
536
+ });
537
+ } finally {
538
+ setLoading(!1);
539
+ }
540
+ }
541
+ }, [
542
+ value,
543
+ client,
544
+ config,
545
+ onChange
546
+ ]);
547
+ return <Stack gap={2}>
548
+ {props.renderDefault(props)}
549
+ {value?.asset && <Stack gap={2}>
550
+ <Flex>
551
+ <Button text={loading ? "Generating…" : "Generate Alt Text"} tone="primary" mode="ghost" disabled={loading || !config.apiKey} onClick={handleGenerate} />
552
+ </Flex>
553
+ {error && <Card tone={error.tone} padding={2} radius={2} border>
554
+ <Text size={1}>{error.message}</Text>
555
+ </Card>}
556
+ {!config.apiKey && <Text size={1} muted>
557
+ No API key configured — open the &quot;Alt Text Generator&quot; tool in the nav to add one.
558
+ </Text>}
559
+ </Stack>}
560
+ </Stack>;
561
+ }
562
+ /**
563
+ * Attaches to the `alt` STRING field itself (not the whole image type), so
564
+ * the "Generate Alt Text" button renders directly under the Alternative Text
565
+ * input instead of below every other field on the image (e.g. Caption).
566
+ *
567
+ * Reads the sibling image value via useFormValue since a field-level input
568
+ * component only gets its own value/path, not the parent object's.
569
+ */
570
+ function AltTextField(props) {
571
+ let { onChange, value = "" } = props, config = useAltTextGeneratorConfig(), client = useClient({ apiVersion: "2024-01-01" }), [loading, setLoading] = useState(!1), [error, setError] = useState(null), imagePath = props.path.slice(0, -1), imageValue = useFormValue(imagePath), handleGenerate = useCallback(async () => {
572
+ if (imageValue?.asset?._ref && config.apiKey) {
573
+ setLoading(!0), setError(null);
574
+ try {
575
+ let { projectId, dataset } = client.config();
576
+ if (!projectId || !dataset) throw Error("Could not determine the Sanity project/dataset for this Studio.");
577
+ let result = await generateAltText(imageUrlBuilder({
578
+ projectId,
579
+ dataset
580
+ }).image(imageValue).url(), config);
581
+ if (!result.ok) {
582
+ setError(result);
583
+ return;
584
+ }
585
+ onChange(set(result.altText));
586
+ } catch (e) {
587
+ setError({
588
+ ok: !1,
589
+ message: e instanceof Error ? e.message : "Network error while generating alt text.",
590
+ tone: "critical"
591
+ });
592
+ } finally {
593
+ setLoading(!1);
594
+ }
595
+ }
596
+ }, [
597
+ imageValue,
598
+ client,
599
+ config,
600
+ onChange
601
+ ]);
602
+ return <Stack gap={2}>
603
+ {props.renderDefault(props)}
604
+ {imageValue?.asset && <Stack gap={2}>
605
+ <Flex>
606
+ <Button text={loading ? "Generating…" : "Generate Alt Text"} tone="primary" mode="ghost" disabled={loading || !config.apiKey} onClick={handleGenerate} />
607
+ </Flex>
608
+ {error && <Card tone={error.tone} padding={2} radius={2} border>
609
+ <Text size={1}>{error.message}</Text>
610
+ </Card>}
611
+ {!config.apiKey && <Text size={1} muted>
612
+ No API key configured — open the &quot;Alt Text Generator&quot; tool in the nav to add one.
613
+ </Text>}
614
+ </Stack>}
615
+ </Stack>;
616
+ }
617
+ /**
618
+ * Usage in `sanity.config.ts`:
619
+ *
620
+ * ```ts
621
+ * import {defineConfig} from 'sanity'
622
+ * import {altTextGenerator} from 'sanity-plugin-alt-text-generator'
623
+ *
624
+ * export default defineConfig({
625
+ * // ...
626
+ * plugins: [altTextGenerator()],
627
+ * })
628
+ * ```
629
+ *
630
+ * No API key required at setup time — open the "Alt Text Generator" tool in
631
+ * the Studio nav and paste your key into its Settings panel (get a free one
632
+ * at alttextgeneratorai.com). You can also pass a default `apiKey` here for
633
+ * local development/CI; the Settings-panel key takes priority once saved.
634
+ *
635
+ * Then wire it into your schema in one of two ways:
636
+ *
637
+ * 1. On the `alt` string field itself (recommended — puts the "Generate Alt
638
+ * Text" button right under Alternative Text, above any other fields like
639
+ * Caption):
640
+ *
641
+ * ```ts
642
+ * defineField({
643
+ * name: 'alt',
644
+ * type: 'string',
645
+ * components: {input: AltTextField},
646
+ * })
647
+ * ```
648
+ *
649
+ * 2. On the whole image field (button renders below all of the image's
650
+ * fields at once):
651
+ *
652
+ * ```ts
653
+ * defineField({
654
+ * name: 'mainImage',
655
+ * type: 'image',
656
+ * components: {input: AltTextImageInput},
657
+ * })
658
+ * ```
659
+ *
660
+ * @public
661
+ */
662
+ const altTextGenerator = definePlugin((config) => ({
663
+ name: "sanity-plugin-alt-text-generator",
664
+ studio: { components: { layout: createAltTextGeneratorProvider(config || {}) } },
665
+ tools: (prev) => [...prev, {
666
+ name: "alt-text-generator",
667
+ title: "Alt Text Generator",
668
+ component: AltTextDashboard
669
+ }]
670
+ }));
671
+ export { AltTextDashboard, AltTextField, AltTextImageInput, altTextGenerator };
672
+
673
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["API_VERSION","API_VERSION","DEFAULT_ENDPOINT","API_VERSION","API_VERSION","API_VERSION"],"sources":["../src/context.ts","../src/settingsStore.ts","../src/AltTextGeneratorProvider.tsx","../src/schemaScan.ts","../src/dashboardData.ts","../src/generateAltText.ts","../src/verifyCredits.ts","../src/SettingsPanel.tsx","../src/AltTextDashboard.tsx","../src/AltTextImageInput.tsx","../src/AltTextField.tsx","../src/index.ts"],"sourcesContent":["import {createContext, useContext} from 'react'\n\nexport interface AltTextGeneratorConfig {\n /**\n * Optional default API key from your Alt Text Generator account\n * (alttextgeneratorai.com). Not required — merchants can instead (or also)\n * paste their key into the plugin's own Settings panel in the Studio, which\n * takes priority when both are set. Passing one here is mainly useful for\n * local development or CI.\n */\n apiKey?: string\n /** Override the generation endpoint — mainly useful for local development. */\n apiEndpoint?: string\n /** Default language for generated alt text, e.g. \"english\", \"spanish\". */\n language?: string\n}\n\nexport interface AltTextGeneratorContextValue {\n apiKey: string\n apiEndpoint?: string\n language?: string\n /** Updates the live, in-memory API key immediately after a successful save from Settings. */\n updateApiKey: (apiKey: string) => void\n}\n\nexport const AltTextGeneratorContext = createContext<AltTextGeneratorContextValue | null>(null)\n\nexport function useAltTextGeneratorConfig(): AltTextGeneratorContextValue {\n const ctx = useContext(AltTextGeneratorContext)\n if (!ctx) {\n throw new Error(\n 'useAltTextGeneratorConfig must be used within the alt-text-generator plugin. ' +\n 'Did you forget to add altTextGenerator() to your sanity.config plugins array?',\n )\n }\n return ctx\n}\n","import type {SanityClient} from 'sanity'\n\n/**\n * A single, well-known document this plugin owns to store its own settings\n * (currently just the API key). Not registered in the consumer's schema —\n * Sanity's document store doesn't require a document's `_type` to be part of\n * the Studio's schema, so this stays entirely internal to the plugin and\n * never shows up in Content/search for the merchant.\n */\nexport const SETTINGS_DOC_ID = 'sanity-plugin-alt-text-generator.settings'\nexport const SETTINGS_DOC_TYPE = 'sanityPluginAltTextGeneratorSettings'\n\nconst API_VERSION = '2024-01-01'\n\nexport async function getSettingsApiKey(client: SanityClient): Promise<string | undefined> {\n const key = await client\n .withConfig({apiVersion: API_VERSION})\n .fetch<string | null>(`*[_id == $id][0].apiKey`, {id: SETTINGS_DOC_ID})\n return key || undefined\n}\n\nexport async function saveSettingsApiKey(client: SanityClient, apiKey: string): Promise<void> {\n await client\n .withConfig({apiVersion: API_VERSION})\n .transaction()\n .createIfNotExists({_id: SETTINGS_DOC_ID, _type: SETTINGS_DOC_TYPE})\n .patch(SETTINGS_DOC_ID, (p) => p.set({apiKey}))\n .commit()\n}\n","import {useEffect, useMemo, useState} from 'react'\nimport {useClient, type LayoutProps} from 'sanity'\nimport {AltTextGeneratorContext, type AltTextGeneratorConfig} from './context'\nimport {getSettingsApiKey} from './settingsStore'\n\nconst API_VERSION = '2024-01-01'\n\n/**\n * Injects the plugin's config into React context for the whole Studio tree,\n * via the `studio.components.layout` slot — this is how a plugin makes its\n * config reachable from an input component deep in a document form, without\n * the merchant having to repeat the API key on every field.\n *\n * The API key can come from two places: the static `apiKey` passed to\n * `altTextGenerator({...})` in sanity.config.ts, and/or the key saved through\n * the plugin's own Settings panel (stored in a document — see\n * settingsStore.ts). The Settings-panel key takes priority once loaded, so a\n * merchant can paste their key into the Studio UI without touching code.\n */\nexport function createAltTextGeneratorProvider(staticConfig: AltTextGeneratorConfig) {\n return function AltTextGeneratorProvider(props: LayoutProps) {\n const client = useClient({apiVersion: API_VERSION})\n const [liveApiKey, setLiveApiKey] = useState<string | undefined>(undefined)\n\n useEffect(() => {\n let cancelled = false\n getSettingsApiKey(client)\n .then((key) => {\n if (!cancelled) setLiveApiKey(key)\n })\n .catch(() => {\n // No settings doc yet, or no read access — fall back to static config silently.\n })\n return () => {\n cancelled = true\n }\n }, [client])\n\n const value = useMemo(\n () => ({\n apiKey: liveApiKey || staticConfig.apiKey || '',\n apiEndpoint: staticConfig.apiEndpoint,\n language: staticConfig.language,\n updateApiKey: setLiveApiKey,\n }),\n [liveApiKey, staticConfig],\n )\n\n return (\n <AltTextGeneratorContext.Provider value={value}>\n {props.renderDefault(props)}\n </AltTextGeneratorContext.Provider>\n )\n }\n}\n","import type {Schema, SchemaType} from 'sanity'\n\nexport interface AltTextFieldTarget {\n documentType: string\n /** e.g. ['mainImage'] or ['seo', 'ogImage'] — joined with '.' for GROQ/patch paths. */\n fieldPath: string[]\n}\n\nfunction chainHasName(type: SchemaType | undefined, name: string): boolean {\n let t: SchemaType | undefined = type\n while (t) {\n if (t.name === name) return true\n t = (t as {type?: SchemaType}).type\n }\n return false\n}\n\nfunction hasAltSubfield(imageType: SchemaType): boolean {\n const fields = (imageType as {fields?: {name: string}[]}).fields || []\n return fields.some((f) => f.name === 'alt')\n}\n\nconst MAX_NESTING_DEPTH = 2\n\nfunction walkFields(\n type: SchemaType,\n path: string[],\n documentType: string,\n out: AltTextFieldTarget[],\n seen: Set<string>,\n depth: number,\n) {\n const fields = (type as {fields?: {name: string; type: SchemaType}[]}).fields\n if (!fields) return\n\n for (const field of fields) {\n const fieldPath = [...path, field.name]\n const fieldType = field.type\n\n if (chainHasName(fieldType, 'image')) {\n if (hasAltSubfield(fieldType)) {\n const key = `${documentType}:${fieldPath.join('.')}`\n if (!seen.has(key)) {\n seen.add(key)\n out.push({documentType, fieldPath})\n }\n }\n continue\n }\n\n // Recurse into plain nested objects (e.g. `seo.ogImage`), skip arrays/references\n // to keep discovery predictable — deeply nested or array-of-image fields are a\n // known gap, not attempted here.\n if (fieldType.jsonType === 'object' && depth < MAX_NESTING_DEPTH) {\n walkFields(fieldType, fieldPath, documentType, out, seen, depth + 1)\n }\n }\n}\n\n/**\n * Walks every document type in the (fully merged, runtime) schema and finds\n * every image field that has an `alt` subfield — the convention this plugin's\n * own AltTextImageInput relies on, and the one most real-world Sanity schemas\n * already use for accessible images either way.\n */\nexport function findAltTextFields(schema: Schema): AltTextFieldTarget[] {\n const results: AltTextFieldTarget[] = []\n const seen = new Set<string>()\n\n for (const typeName of schema.getTypeNames()) {\n const type = schema.get(typeName)\n if (!type || !chainHasName(type, 'document')) continue\n walkFields(type, [], typeName, results, seen, 0)\n }\n\n return results\n}\n","import type {SanityClient} from 'sanity'\nimport type {AltTextFieldTarget} from './schemaScan'\n\nexport interface AltTextRow {\n key: string\n documentId: string\n documentType: string\n documentTitle: string\n fieldPath: string[]\n fieldLabel: string\n imageUrl: string | null\n alt: string\n}\n\nfunction fieldAlias(fieldPath: string[]): string {\n return 'f_' + fieldPath.join('_')\n}\n\nfunction groqPath(fieldPath: string[]): string {\n return fieldPath.join('.')\n}\n\n/**\n * One GROQ query per document type (fields are grouped by documentType first),\n * projecting every discovered image field on that type plus a best-effort title.\n */\nfunction buildQuery(documentType: string, fields: AltTextFieldTarget[]): string {\n const projections = fields\n .map((f) => {\n const alias = fieldAlias(f.fieldPath)\n const path = groqPath(f.fieldPath)\n return `\"${alias}\": ${path}{alt, \"url\": asset->url}`\n })\n .join(',\\n ')\n\n return `*[_type == \"${documentType}\"]{\n _id,\n _type,\n \"documentTitle\": coalesce(title, name, heading, label, _id),\n ${projections}\n }`\n}\n\nexport async function fetchAltTextRows(\n client: SanityClient,\n targets: AltTextFieldTarget[],\n): Promise<AltTextRow[]> {\n const byType = new Map<string, AltTextFieldTarget[]>()\n for (const t of targets) {\n const list = byType.get(t.documentType) || []\n list.push(t)\n byType.set(t.documentType, list)\n }\n\n const rows: AltTextRow[] = []\n\n for (const [documentType, fields] of byType) {\n const query = buildQuery(documentType, fields)\n const docs = await client.fetch<Record<string, unknown>[]>(query)\n\n for (const doc of docs) {\n for (const f of fields) {\n const alias = fieldAlias(f.fieldPath)\n const value = doc[alias] as {alt?: string; url?: string} | null | undefined\n if (!value || !value.url) continue // field empty on this doc — nothing to show\n\n rows.push({\n key: `${doc._id}:${f.fieldPath.join('.')}`,\n documentId: doc._id as string,\n documentType,\n documentTitle: (doc.documentTitle as string) || (doc._id as string),\n fieldPath: f.fieldPath,\n fieldLabel: f.fieldPath.join(' › '),\n imageUrl: value.url,\n alt: value.alt || '',\n })\n }\n }\n }\n\n return rows\n}\n\nexport interface AltTextUpdate {\n documentId: string\n fieldPath: string[]\n alt: string\n}\n\n/** Saves every update in one transaction — either all land or none do. */\nexport async function saveAltTextUpdates(client: SanityClient, updates: AltTextUpdate[]): Promise<void> {\n if (!updates.length) return\n\n let tx = client.transaction()\n for (const u of updates) {\n const patchKey = `${groqPath(u.fieldPath)}.alt`\n tx = tx.patch(u.documentId, (p) => p.set({[patchKey]: u.alt}))\n }\n await tx.commit()\n}\n","import type {AltTextGeneratorConfig} from './context'\n\nconst DEFAULT_ENDPOINT = 'https://alttextgeneratorai.com/api/sanity'\n\nexport type GenerateResult =\n | {ok: true; altText: string; creditsLeft: number | null}\n | {ok: false; message: string; tone: 'critical' | 'caution'}\n\nfunction messageForStatus(status: number, body: string): {message: string; tone: 'critical' | 'caution'} {\n if (status === 401) return {message: 'Invalid API key. Check it under the plugin config.', tone: 'critical'}\n if (status === 402) return {message: 'No credits left on this API key.', tone: 'caution'}\n return {message: body || `Generation failed (${status}).`, tone: 'critical'}\n}\n\nexport async function generateAltText(imageUrl: string, config: AltTextGeneratorConfig): Promise<GenerateResult> {\n try {\n const endpoint = config.apiEndpoint || DEFAULT_ENDPOINT\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n image: imageUrl,\n wpkey: config.apiKey,\n language: config.language || 'english',\n }),\n })\n\n const body = await res.text()\n\n if (!res.ok) {\n return {ok: false, ...messageForStatus(res.status, body)}\n }\n\n const creditsHeader = res.headers.get('X-Credits-Left')\n return {\n ok: true,\n altText: body.trim(),\n creditsLeft: creditsHeader === null ? null : Number(creditsHeader),\n }\n } catch (e) {\n return {\n ok: false,\n message: e instanceof Error ? e.message : 'Network error while generating alt text.',\n tone: 'critical',\n }\n }\n}\n","import type {AltTextGeneratorConfig} from './context'\n\nconst DEFAULT_ENDPOINT = 'https://alttextgeneratorai.com/api/sanity'\n\n/**\n * The credits-check endpoint isn't Sanity-specific — it's the same\n * `/api/verify` every plugin (Framer, WordPress, Magento...) already calls.\n * Derived from the generation endpoint so a local-dev `apiEndpoint` override\n * (e.g. http://localhost:3000/api/sanity) checks credits against the same\n * local backend instead of production.\n */\nfunction verifyEndpointFor(config: AltTextGeneratorConfig): string {\n const base = config.apiEndpoint || DEFAULT_ENDPOINT\n return base.replace(/\\/[^/]+$/, '/verify')\n}\n\nexport async function fetchCreditsRemaining(config: AltTextGeneratorConfig, apiKey: string): Promise<number | null> {\n if (!apiKey) return null\n try {\n const res = await fetch(verifyEndpointFor(config), {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({apiKey}),\n })\n if (!res.ok) return null\n const data = (await res.json()) as {freeRewritesLeft?: number}\n return typeof data.freeRewritesLeft === 'number' ? data.freeRewritesLeft : null\n } catch {\n return null\n }\n}\n","import {useCallback, useEffect, useState} from 'react'\nimport {Box, Button, Card, Flex, Stack, Text, TextInput} from '@sanity/ui'\nimport {useClient} from 'sanity'\nimport {useAltTextGeneratorConfig} from './context'\nimport {saveSettingsApiKey} from './settingsStore'\nimport {fetchCreditsRemaining} from './verifyCredits'\n\nconst API_VERSION = '2024-01-01'\nconst GET_KEY_URL = 'https://alttextgeneratorai.com/dashboard'\n\n/**\n * Lets a merchant paste their API key directly in the Studio, no code edit\n * required — saved to a plugin-internal settings document (settingsStore.ts)\n * and pushed straight into the shared context so every open field/tool picks\n * it up immediately. Also shows the current credit balance for the active key.\n */\nexport function SettingsPanel() {\n const client = useClient({apiVersion: API_VERSION})\n const config = useAltTextGeneratorConfig()\n\n const [keyInput, setKeyInput] = useState(config.apiKey)\n const [saving, setSaving] = useState(false)\n const [justSaved, setJustSaved] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [credits, setCredits] = useState<number | null>(null)\n const [checkingCredits, setCheckingCredits] = useState(false)\n\n // Keep the input in sync if the live key changes elsewhere (e.g. loaded after mount).\n useEffect(() => {\n setKeyInput(config.apiKey)\n }, [config.apiKey])\n\n const checkCredits = useCallback(\n async (apiKey: string) => {\n if (!apiKey) {\n setCredits(null)\n return\n }\n setCheckingCredits(true)\n const result = await fetchCreditsRemaining(config, apiKey)\n setCredits(result)\n setCheckingCredits(false)\n },\n [config],\n )\n\n useEffect(() => {\n checkCredits(config.apiKey)\n // Only re-check when the live key itself changes, not on every config identity change.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [config.apiKey])\n\n const handleSave = useCallback(async () => {\n setSaving(true)\n setError(null)\n setJustSaved(false)\n try {\n const trimmed = keyInput.trim()\n await saveSettingsApiKey(client, trimmed)\n config.updateApiKey(trimmed)\n setJustSaved(true)\n checkCredits(trimmed)\n } catch (e) {\n setError(e instanceof Error ? e.message : 'Failed to save.')\n } finally {\n setSaving(false)\n }\n }, [client, keyInput, config, checkCredits])\n\n return (\n <Card padding={4} radius={2} border tone=\"transparent\">\n <Stack gap={3}>\n <Text size={1} weight=\"medium\">\n API Key\n </Text>\n <Text size={1} muted>\n Paste your Alt Text Generator API key below.{' '}\n <a href={GET_KEY_URL} target=\"_blank\" rel=\"noreferrer\">\n Get a free API key →\n </a>\n </Text>\n <Flex gap={2} align=\"center\" wrap=\"wrap\">\n <Box flex={1} style={{minWidth: 240}}>\n <TextInput\n value={keyInput}\n placeholder=\"Paste your API key\"\n onChange={(e) => setKeyInput(e.currentTarget.value)}\n />\n </Box>\n <Button\n text={saving ? 'Saving…' : 'Save'}\n tone=\"primary\"\n disabled={saving || keyInput.trim() === config.apiKey}\n onClick={handleSave}\n />\n </Flex>\n {justSaved && (\n <Text size={1} style={{color: 'var(--card-positive-fg-color, #2a9d5c)'}}>\n Saved.\n </Text>\n )}\n {error && (\n <Text size={1} style={{color: 'var(--card-critical-fg-color, #e02b27)'}}>\n {error}\n </Text>\n )}\n {!config.apiKey && (\n <Text size={1} style={{color: 'var(--card-caution-fg-color, #a56a0d)'}}>\n No API key configured yet — generation will fail until you add one.\n </Text>\n )}\n {config.apiKey && (\n <Text size={1} muted>\n {checkingCredits\n ? 'Checking credits…'\n : credits === null\n ? 'Could not check credits for this key.'\n : `${credits} credit${credits === 1 ? '' : 's'} remaining`}\n </Text>\n )}\n </Stack>\n </Card>\n )\n}\n","import {useCallback, useMemo, useRef, useState} from 'react'\nimport {\n Box,\n Button,\n Card,\n Checkbox,\n Container,\n Flex,\n Heading,\n Stack,\n Text,\n TextArea,\n Spinner,\n} from '@sanity/ui'\nimport {useClient, useSchema} from 'sanity'\nimport {findAltTextFields} from './schemaScan'\nimport {fetchAltTextRows, saveAltTextUpdates, type AltTextRow} from './dashboardData'\nimport {generateAltText} from './generateAltText'\nimport {useAltTextGeneratorConfig} from './context'\nimport {SettingsPanel} from './SettingsPanel'\n\nconst API_VERSION = '2024-01-01'\n\ntype Filter = 'missing' | 'all'\n\n/**\n * The bulk companion to AltTextImageInput: scans every document type in the\n * schema for image fields that have an `alt` subfield (see schemaScan.ts),\n * lists every image found across the dataset, and lets the user review,\n * generate, and save alt text for many images in one pass.\n */\nexport function AltTextDashboard() {\n const schema = useSchema()\n const client = useClient({apiVersion: API_VERSION})\n const config = useAltTextGeneratorConfig()\n\n const [scanning, setScanning] = useState(false)\n const [scanError, setScanError] = useState<string | null>(null)\n const [rows, setRows] = useState<AltTextRow[]>([])\n const [scannedTypes, setScannedTypes] = useState(0)\n\n const [filter, setFilter] = useState<Filter>('missing')\n const [overwrite, setOverwrite] = useState(false)\n const [selected, setSelected] = useState<Set<string>>(new Set())\n const [dirty, setDirty] = useState<Record<string, string>>({}) // key -> new alt text\n const [rowErrors, setRowErrors] = useState<Record<string, string>>({})\n\n const [bulkRunning, setBulkRunning] = useState(false)\n const [progress, setProgress] = useState('')\n const [credits, setCredits] = useState<number | null>(null)\n const [saving, setSaving] = useState(false)\n const stopRequested = useRef(false)\n\n const targets = useMemo(() => findAltTextFields(schema), [schema])\n\n const handleScan = useCallback(async () => {\n setScanning(true)\n setScanError(null)\n try {\n const found = await fetchAltTextRows(client, targets)\n setRows(found)\n setScannedTypes(new Set(targets.map((t) => t.documentType)).size)\n setDirty({})\n setSelected(new Set())\n setRowErrors({})\n } catch (e) {\n setScanError(e instanceof Error ? e.message : 'Scan failed.')\n } finally {\n setScanning(false)\n }\n }, [client, targets])\n\n const currentAlt = useCallback(\n (row: AltTextRow) => (row.key in dirty ? dirty[row.key] : row.alt),\n [dirty],\n )\n\n const visibleRows = useMemo(() => {\n return rows.filter((row) => {\n if (filter === 'all') return true\n const text = currentAlt(row)\n return !text || text.trim() === ''\n })\n }, [rows, filter, currentAlt])\n\n const isLocked = useCallback(\n (row: AltTextRow) => !overwrite && currentAlt(row).trim() !== '',\n [overwrite, currentAlt],\n )\n\n const setRowText = useCallback((key: string, text: string) => {\n setDirty((prev) => ({...prev, [key]: text}))\n }, [])\n\n const generateOne = useCallback(\n async (row: AltTextRow): Promise<{ok: boolean; message?: string}> => {\n setRowErrors((prev) => {\n const next = {...prev}\n delete next[row.key]\n return next\n })\n if (!row.imageUrl) return {ok: false, message: 'No image URL.'}\n\n const result = await generateAltText(row.imageUrl, config)\n if (!result.ok) {\n setRowErrors((prev) => ({...prev, [row.key]: result.message}))\n return {ok: false, message: result.message}\n }\n setRowText(row.key, result.altText)\n if (result.creditsLeft !== null) setCredits(result.creditsLeft)\n return {ok: true}\n },\n [config, setRowText],\n )\n\n const runBulk = useCallback(\n async (list: AltTextRow[]) => {\n setBulkRunning(true)\n stopRequested.current = false\n let done = 0\n let applied = 0\n let failed = 0\n let lastError = ''\n\n for (const row of list) {\n if (stopRequested.current) break\n const result = await generateOne(row)\n done++\n if (result.ok) {\n applied++\n } else {\n failed++\n lastError = result.message || ''\n if (lastError.toLowerCase().includes('credits')) {\n setProgress(`Stopped: out of credits. ${applied} generated.`)\n break\n }\n }\n setProgress(\n `${done} / ${list.length} · ${applied} generated` +\n (failed ? ` · ${failed} failed — last error: ${lastError}` : ''),\n )\n }\n\n setBulkRunning(false)\n },\n [generateOne],\n )\n\n const handleGenerateSelected = useCallback(() => {\n const list = visibleRows.filter((r) => selected.has(r.key) && !isLocked(r))\n if (!list.length) return\n runBulk(list)\n }, [visibleRows, selected, isLocked, runBulk])\n\n const handleGenerateAllMissing = useCallback(() => {\n const list = rows.filter((r) => currentAlt(r).trim() === '')\n if (!list.length) return\n runBulk(list)\n }, [rows, currentAlt, runBulk])\n\n const handleSave = useCallback(async () => {\n const updates = Object.entries(dirty).map(([key, alt]) => {\n const row = rows.find((r) => r.key === key)!\n return {documentId: row.documentId, fieldPath: row.fieldPath, alt}\n })\n if (!updates.length) return\n\n setSaving(true)\n try {\n await saveAltTextUpdates(client, updates)\n // Reflect saved values into the base rows and clear dirty state.\n setRows((prev) =>\n prev.map((r) => (r.key in dirty ? {...r, alt: dirty[r.key]} : r)),\n )\n setDirty({})\n setProgress(`Saved ${updates.length} image(s).`)\n } catch (e) {\n setProgress(`Save failed: ${e instanceof Error ? e.message : 'unknown error'}`)\n } finally {\n setSaving(false)\n }\n }, [dirty, rows, client])\n\n const toggleSelectAll = useCallback(\n (checked: boolean) => {\n setSelected(checked ? new Set(visibleRows.filter((r) => !isLocked(r)).map((r) => r.key)) : new Set())\n },\n [visibleRows, isLocked],\n )\n\n const missingCount = rows.filter((r) => currentAlt(r).trim() === '').length\n\n return (\n <Container width={4} padding={4}>\n <Stack gap={4}>\n <Heading size={2}>Alt Text Generator AI</Heading>\n\n <SettingsPanel />\n\n <Flex align=\"center\" gap={3}>\n <Button text={scanning ? 'Scanning…' : 'Scan Dataset'} tone=\"primary\" disabled={scanning} onClick={handleScan} />\n {rows.length > 0 && (\n <Text size={1} muted>\n {rows.length} image{rows.length === 1 ? '' : 's'} · {missingCount} missing alt text (scanned {scannedTypes}{' '}\n document type{scannedTypes === 1 ? '' : 's'})\n </Text>\n )}\n {scanning && <Spinner muted />}\n </Flex>\n\n {scanError && (\n <Card tone=\"critical\" padding={3} radius={2} border>\n <Text size={1}>{scanError}</Text>\n </Card>\n )}\n\n {targets.length === 0 && !scanning && (\n <Card tone=\"caution\" padding={3} radius={2} border>\n <Text size={1}>\n No image fields with an &quot;alt&quot; subfield were found in this Studio&apos;s schema. Add an{' '}\n <code>alt</code> string field to an image type (see the README) for it to show up here.\n </Text>\n </Card>\n )}\n\n {rows.length > 0 && (\n <Stack gap={4}>\n <Flex align=\"center\" gap={3}>\n <Button\n text=\"Missing\"\n mode={filter === 'missing' ? 'default' : 'ghost'}\n tone={filter === 'missing' ? 'primary' : 'default'}\n onClick={() => setFilter('missing')}\n />\n <Button\n text=\"All\"\n mode={filter === 'all' ? 'default' : 'ghost'}\n tone={filter === 'all' ? 'primary' : 'default'}\n onClick={() => setFilter('all')}\n />\n <Flex align=\"center\" gap={2}>\n <Checkbox checked={overwrite} onChange={(e) => setOverwrite(e.currentTarget.checked)} id=\"atg-overwrite\" />\n <Text as=\"label\" size={1} htmlFor=\"atg-overwrite\">\n Overwrite images that already have alt text\n </Text>\n </Flex>\n </Flex>\n\n <Flex align=\"center\" gap={2}>\n <Checkbox\n checked={visibleRows.length > 0 && visibleRows.every((r) => isLocked(r) || selected.has(r.key))}\n onChange={(e) => toggleSelectAll(e.currentTarget.checked)}\n id=\"atg-select-all\"\n />\n <Text as=\"label\" size={1} htmlFor=\"atg-select-all\">\n Select all visible {selected.size > 0 && `(${selected.size} selected)`}\n </Text>\n </Flex>\n\n <Card border radius={2} style={{maxHeight: 520, overflowY: 'auto'}}>\n <Stack gap={0}>\n {visibleRows.map((row) => {\n const locked = isLocked(row)\n const text = currentAlt(row)\n const error = rowErrors[row.key]\n return (\n <Flex key={row.key} padding={3} gap={3} align=\"flex-start\" style={{borderBottom: '1px solid var(--card-border-color)'}}>\n <Box paddingTop={1}>\n <Checkbox\n checked={selected.has(row.key)}\n disabled={locked}\n onChange={(e) => {\n // Extract the primitive before it reaches the state\n // updater — React can invoke that updater outside the\n // original synchronous event (Strict Mode double-invokes\n // it), and by then `e.currentTarget` is already null.\n const checked = e.currentTarget.checked\n setSelected((prev) => {\n const next = new Set(prev)\n if (checked) next.add(row.key)\n else next.delete(row.key)\n return next\n })\n }}\n />\n </Box>\n {row.imageUrl && (\n <img\n src={`${row.imageUrl}?w=80&h=80&fit=crop`}\n width={56}\n height={56}\n style={{objectFit: 'cover', borderRadius: 4, flexShrink: 0}}\n alt=\"\"\n />\n )}\n <Box style={{width: 220, flexShrink: 0}}>\n <Text size={1} weight=\"medium\">\n {row.documentTitle}\n </Text>\n <Text size={1} muted>\n {row.documentType} · {row.fieldLabel}\n </Text>\n </Box>\n <Box flex={1}>\n <Stack gap={2}>\n <TextArea\n rows={2}\n value={text}\n disabled={locked}\n onChange={(e) => setRowText(row.key, e.currentTarget.value)}\n />\n {error && (\n <Text size={1} style={{color: 'var(--card-critical-fg-color, #e02b27)'}}>\n {error}\n </Text>\n )}\n </Stack>\n </Box>\n <Box paddingTop={1}>\n <Button\n text=\"Generate\"\n mode=\"ghost\"\n fontSize={1}\n disabled={locked || bulkRunning}\n onClick={() => generateOne(row)}\n />\n </Box>\n </Flex>\n )\n })}\n </Stack>\n </Card>\n\n <Flex align=\"center\" gap={3} wrap=\"wrap\">\n <Button\n text=\"Generate Selected\"\n tone=\"primary\"\n disabled={bulkRunning || selected.size === 0}\n onClick={handleGenerateSelected}\n />\n <Button\n text=\"Generate All Missing\"\n mode=\"ghost\"\n disabled={bulkRunning || missingCount === 0}\n onClick={handleGenerateAllMissing}\n />\n {bulkRunning && (\n <Button text=\"Stop\" mode=\"ghost\" tone=\"critical\" onClick={() => (stopRequested.current = true)} />\n )}\n <Button\n text={saving ? 'Saving…' : 'Save Changes'}\n tone=\"positive\"\n disabled={saving || Object.keys(dirty).length === 0}\n onClick={handleSave}\n />\n {credits !== null && (\n <Text size={1} muted>\n ({credits} credits remaining)\n </Text>\n )}\n </Flex>\n\n {progress && (\n <Text size={1} muted>\n {progress}\n </Text>\n )}\n </Stack>\n )}\n </Stack>\n </Container>\n )\n}\n","import {useCallback, useState} from 'react'\nimport {Button, Card, Flex, Stack, Text} from '@sanity/ui'\nimport {set, useClient, type ImageInputProps} from 'sanity'\nimport imageUrlBuilder from '@sanity/image-url'\nimport {useAltTextGeneratorConfig} from './context'\nimport {generateAltText, type GenerateResult} from './generateAltText'\n\nconst API_VERSION = '2024-01-01'\n\n/**\n * Drop-in replacement for the default image input: renders the normal image\n * field (upload, crop, hotspot — via `renderDefault`) plus a \"Generate Alt\n * Text\" button that sends the uploaded asset's CDN URL to the Alt Text\n * Generator API and writes the result into the image's `alt` field.\n */\nexport function AltTextImageInput(props: ImageInputProps) {\n const {value, onChange} = props\n const config = useAltTextGeneratorConfig()\n const client = useClient({apiVersion: API_VERSION})\n const [loading, setLoading] = useState(false)\n const [error, setError] = useState<Extract<GenerateResult, {ok: false}> | null>(null)\n\n const handleGenerate = useCallback(async () => {\n if (!value?.asset?._ref || !config.apiKey) return\n setLoading(true)\n setError(null)\n\n try {\n const {projectId, dataset} = client.config()\n if (!projectId || !dataset) {\n throw new Error('Could not determine the Sanity project/dataset for this Studio.')\n }\n const imageUrl = imageUrlBuilder({projectId, dataset}).image(value).url()\n\n const result = await generateAltText(imageUrl, config)\n if (!result.ok) {\n setError(result)\n return\n }\n onChange(set(result.altText, ['alt']))\n } catch (e) {\n setError({\n ok: false,\n message: e instanceof Error ? e.message : 'Network error while generating alt text.',\n tone: 'critical',\n })\n } finally {\n setLoading(false)\n }\n }, [value, client, config, onChange])\n\n return (\n <Stack gap={2}>\n {props.renderDefault(props)}\n {value?.asset && (\n <Stack gap={2}>\n <Flex>\n <Button\n text={loading ? 'Generating…' : 'Generate Alt Text'}\n tone=\"primary\"\n mode=\"ghost\"\n disabled={loading || !config.apiKey}\n onClick={handleGenerate}\n />\n </Flex>\n {error && (\n <Card tone={error.tone} padding={2} radius={2} border>\n <Text size={1}>{error.message}</Text>\n </Card>\n )}\n {!config.apiKey && (\n <Text size={1} muted>\n No API key configured — open the &quot;Alt Text Generator&quot; tool in the nav to add one.\n </Text>\n )}\n </Stack>\n )}\n </Stack>\n )\n}\n","import {useCallback, useState} from 'react'\nimport {Button, Card, Flex, Stack, Text} from '@sanity/ui'\nimport {set, useClient, useFormValue, type StringInputProps} from 'sanity'\nimport imageUrlBuilder from '@sanity/image-url'\nimport {useAltTextGeneratorConfig} from './context'\nimport {generateAltText, type GenerateResult} from './generateAltText'\n\nconst API_VERSION = '2024-01-01'\n\ntype ImageWithAsset = {asset?: {_ref: string}}\n\n/**\n * Attaches to the `alt` STRING field itself (not the whole image type), so\n * the \"Generate Alt Text\" button renders directly under the Alternative Text\n * input instead of below every other field on the image (e.g. Caption).\n *\n * Reads the sibling image value via useFormValue since a field-level input\n * component only gets its own value/path, not the parent object's.\n */\nexport function AltTextField(props: StringInputProps) {\n const {onChange, value = ''} = props\n const config = useAltTextGeneratorConfig()\n const client = useClient({apiVersion: API_VERSION})\n const [loading, setLoading] = useState(false)\n const [error, setError] = useState<Extract<GenerateResult, {ok: false}> | null>(null)\n\n // props.path is e.g. ['mainImage', 'alt'] — the image value lives one level up.\n const imagePath = props.path.slice(0, -1)\n const imageValue = useFormValue(imagePath) as ImageWithAsset | undefined\n\n const handleGenerate = useCallback(async () => {\n if (!imageValue?.asset?._ref || !config.apiKey) return\n setLoading(true)\n setError(null)\n\n try {\n const {projectId, dataset} = client.config()\n if (!projectId || !dataset) {\n throw new Error('Could not determine the Sanity project/dataset for this Studio.')\n }\n const imageUrl = imageUrlBuilder({projectId, dataset}).image(imageValue).url()\n\n const result = await generateAltText(imageUrl, config)\n if (!result.ok) {\n setError(result)\n return\n }\n onChange(set(result.altText))\n } catch (e) {\n setError({\n ok: false,\n message: e instanceof Error ? e.message : 'Network error while generating alt text.',\n tone: 'critical',\n })\n } finally {\n setLoading(false)\n }\n }, [imageValue, client, config, onChange])\n\n return (\n <Stack gap={2}>\n {props.renderDefault(props)}\n {imageValue?.asset && (\n <Stack gap={2}>\n <Flex>\n <Button\n text={loading ? 'Generating…' : 'Generate Alt Text'}\n tone=\"primary\"\n mode=\"ghost\"\n disabled={loading || !config.apiKey}\n onClick={handleGenerate}\n />\n </Flex>\n {error && (\n <Card tone={error.tone} padding={2} radius={2} border>\n <Text size={1}>{error.message}</Text>\n </Card>\n )}\n {!config.apiKey && (\n <Text size={1} muted>\n No API key configured — open the &quot;Alt Text Generator&quot; tool in the nav to add one.\n </Text>\n )}\n </Stack>\n )}\n </Stack>\n )\n}\n","import {definePlugin} from 'sanity'\nimport type {AltTextGeneratorConfig} from './context'\nimport {createAltTextGeneratorProvider} from './AltTextGeneratorProvider'\nimport {AltTextDashboard} from './AltTextDashboard'\n\nexport {AltTextImageInput} from './AltTextImageInput'\nexport {AltTextField} from './AltTextField'\nexport {AltTextDashboard} from './AltTextDashboard'\nexport type {AltTextGeneratorConfig} from './context'\n\n/**\n * Usage in `sanity.config.ts`:\n *\n * ```ts\n * import {defineConfig} from 'sanity'\n * import {altTextGenerator} from 'sanity-plugin-alt-text-generator'\n *\n * export default defineConfig({\n * // ...\n * plugins: [altTextGenerator()],\n * })\n * ```\n *\n * No API key required at setup time — open the \"Alt Text Generator\" tool in\n * the Studio nav and paste your key into its Settings panel (get a free one\n * at alttextgeneratorai.com). You can also pass a default `apiKey` here for\n * local development/CI; the Settings-panel key takes priority once saved.\n *\n * Then wire it into your schema in one of two ways:\n *\n * 1. On the `alt` string field itself (recommended — puts the \"Generate Alt\n * Text\" button right under Alternative Text, above any other fields like\n * Caption):\n *\n * ```ts\n * defineField({\n * name: 'alt',\n * type: 'string',\n * components: {input: AltTextField},\n * })\n * ```\n *\n * 2. On the whole image field (button renders below all of the image's\n * fields at once):\n *\n * ```ts\n * defineField({\n * name: 'mainImage',\n * type: 'image',\n * components: {input: AltTextImageInput},\n * })\n * ```\n *\n * @public\n */\nexport const altTextGenerator = definePlugin<AltTextGeneratorConfig | void>((config) => {\n const staticConfig = config || {}\n\n return {\n name: 'sanity-plugin-alt-text-generator',\n studio: {\n components: {\n layout: createAltTextGeneratorProvider(staticConfig),\n },\n },\n tools: (prev) => [\n ...prev,\n {\n name: 'alt-text-generator',\n title: 'Alt Text Generator',\n component: AltTextDashboard,\n },\n ],\n }\n})\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "sanity-plugin-alt-text-generator",
3
+ "version": "1.0.0",
4
+ "description": "Generate AI-powered, SEO-friendly alt text for images inside Sanity Studio — per-field, or in bulk across your whole dataset from a built-in dashboard.",
5
+ "keywords": [
6
+ "sanity",
7
+ "sanity-plugin",
8
+ "alt-text",
9
+ "accessibility",
10
+ "seo",
11
+ "ai",
12
+ "images"
13
+ ],
14
+ "homepage": "https://github.com/balt1794/sanity-plugin-alt-text-generator#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/balt1794/sanity-plugin-alt-text-generator/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/balt1794/sanity-plugin-alt-text-generator.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Bryam Loaiza <balt1794@gmail.com>",
24
+ "browserslist": "extends @sanity/browserslist-config",
25
+ "sideEffects": false,
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "source": "./src/index.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "types": "./dist/index.d.ts",
35
+ "files": [
36
+ "dist"
37
+ ],
38
+ "scripts": {
39
+ "build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean",
40
+ "format": "oxfmt",
41
+ "link-watch": "plugin-kit link-watch",
42
+ "lint": "oxlint",
43
+ "prepublishOnly": "npm run build",
44
+ "watch": "pkg-utils watch --strict"
45
+ },
46
+ "dependencies": {
47
+ "@sanity/image-url": "^2.1.1",
48
+ "@sanity/ui": "^4.2.1"
49
+ },
50
+ "devDependencies": {
51
+ "@sanity/browserslist-config": "^1.0.5",
52
+ "@sanity/pkg-utils": "^13.0.0",
53
+ "@sanity/plugin-kit": "^10.0.9",
54
+ "@types/react": "^19.3.0",
55
+ "oxfmt": "^0.68.0",
56
+ "oxlint": "^1.83.0",
57
+ "oxlint-tsgolint": "^7.0.2001",
58
+ "react": "^19.3.0",
59
+ "react-dom": "^19.3.0",
60
+ "sanity": "^6.13.2",
61
+ "styled-components": "^6.5.3",
62
+ "typescript": "^6.0.3"
63
+ },
64
+ "peerDependencies": {
65
+ "react": "^18",
66
+ "sanity": "^5 || ^6.0.0-0"
67
+ },
68
+ "engines": {
69
+ "node": ">=20.19 <22 || >=22.12"
70
+ },
71
+ "publishConfig": {
72
+ "exports": {
73
+ ".": "./dist/index.js",
74
+ "./package.json": "./package.json"
75
+ }
76
+ }
77
+ }