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 +21 -0
- package/README.md +89 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +673 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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 "alt" subfield were found in this Studio'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 "Alt Text Generator" 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 "Alt Text Generator" 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 "alt" subfield were found in this Studio'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 "Alt Text Generator" 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 "Alt Text Generator" 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
|
+
}
|