@liiift-studio/deploy-vercel-from-sanity 1.2.0 → 1.2.1

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.
@@ -1,172 +0,0 @@
1
- // Dialog form for creating and editing vercel_deploy documents
2
- import { useId, useState, useCallback } from 'react'
3
- import { useClient } from 'sanity'
4
- import { CheckmarkCircleIcon } from '../icons'
5
- import type { DeployTarget } from '../types'
6
- import { Box, Button, Card, Dialog, Flex, Label, Stack, Switch, Text, TextInput } from '../compat'
7
-
8
- const VERCEL_HOOK_RE = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\//
9
-
10
- interface DeployTargetFormProps {
11
- /** When provided, form is in edit mode; otherwise create mode */
12
- initial?: DeployTarget
13
- onSaved: () => void
14
- onClose: () => void
15
- }
16
-
17
- export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetFormProps) {
18
- const client = useClient({ apiVersion: '2025-01-01' })
19
- const isEdit = Boolean(initial)
20
-
21
- // Ids for label/description association. useId keeps them unique inside a published
22
- // plugin, where a hardcoded id can collide with anything the host Studio renders.
23
- const nameId = useId()
24
- const urlId = useId()
25
- const urlErrorId = useId()
26
- const urlHelpId = useId()
27
- const teamId_ = useId()
28
- const teamHelpId = useId()
29
- const disableId = useId()
30
-
31
- const [name, setName] = useState(initial?.name ?? '')
32
- const [url, setUrl] = useState(initial?.url ?? '')
33
- const [teamId, setTeamId] = useState(initial?.teamId ?? '')
34
- const [disableDelete, setDisableDelete] = useState(initial?.disableDeleteAction ?? false)
35
- const [saving, setSaving] = useState(false)
36
- const [error, setError] = useState<string | null>(null)
37
-
38
- const urlValid = !url || VERCEL_HOOK_RE.test(url.trim())
39
- const canSave = name.trim() && url.trim() && urlValid
40
-
41
- const save = useCallback(async () => {
42
- if (!canSave) return
43
- setSaving(true)
44
- setError(null)
45
- const fields: { name: string; url: string; teamId: string | null; disableDeleteAction: boolean } = {
46
- name: name.trim(),
47
- url: url.trim(),
48
- teamId: teamId.trim() || null,
49
- disableDeleteAction: disableDelete,
50
- }
51
- try {
52
- if (isEdit && initial) {
53
- await client.patch(initial._id).set(fields).commit()
54
- } else {
55
- await client.create({ _type: 'vercel_deploy', ...fields })
56
- }
57
- onSaved()
58
- } catch (err) {
59
- setError(err instanceof Error ? err.message : 'Save failed')
60
- } finally {
61
- setSaving(false)
62
- }
63
- }, [canSave, isEdit, initial, client, name, url, teamId, disableDelete, onSaved])
64
-
65
- return (
66
- <Dialog
67
- header={isEdit ? `Edit "${initial?.name}"` : 'Add deploy target'}
68
- id="deploy-target-form"
69
- onClose={onClose}
70
- width={1}
71
- footer={
72
- <Flex padding={3} gap={2} justify="flex-end">
73
- <Button text="Cancel" mode="ghost" onClick={onClose} style={{ cursor: 'pointer' }} />
74
- <Button
75
- text={isEdit ? 'Save changes' : 'Add target'}
76
- tone="primary"
77
- icon={CheckmarkCircleIcon}
78
- loading={saving}
79
- disabled={!canSave || saving}
80
- onClick={save}
81
- style={{ cursor: 'pointer' }}
82
- />
83
- </Flex>
84
- }
85
- >
86
- <Box padding={4}>
87
- <Stack space={4}>
88
-
89
- {/* Name. Label carries `as="label"` — @sanity/ui's Label renders a div otherwise,
90
- so the input would have no accessible name. */}
91
- <Stack space={2}>
92
- <Label as="label" size={1} htmlFor={nameId}>
93
- Name <span aria-hidden="true">*</span>
94
- </Label>
95
- <TextInput
96
- id={nameId}
97
- required
98
- aria-required="true"
99
- value={name}
100
- onChange={e => setName((e.target as HTMLInputElement).value)}
101
- placeholder="Production"
102
- />
103
- </Stack>
104
-
105
- {/* Deploy hook URL */}
106
- <Stack space={2}>
107
- <Label as="label" size={1} htmlFor={urlId}>
108
- Deploy hook URL <span aria-hidden="true">*</span>
109
- </Label>
110
- <TextInput
111
- id={urlId}
112
- required
113
- aria-required="true"
114
- aria-invalid={Boolean(url) && !urlValid}
115
- aria-describedby={`${urlHelpId}${url && !urlValid ? ` ${urlErrorId}` : ''}`}
116
- value={url}
117
- onChange={e => setUrl((e.target as HTMLInputElement).value)}
118
- placeholder="https://api.vercel.com/v1/integrations/deploy/…"
119
- />
120
- {url && !urlValid && (
121
- <Card tone="critical" padding={2} radius={2} role="alert">
122
- <Text id={urlErrorId} size={1}>
123
- Must be a Vercel deploy hook URL (api.vercel.com/v1/integrations/deploy/…)
124
- </Text>
125
- </Card>
126
- )}
127
- <Text id={urlHelpId} size={0} muted>
128
- Vercel dashboard → Project → Settings → Git → Deploy Hooks
129
- </Text>
130
- </Stack>
131
-
132
- {/* Team ID */}
133
- <Stack space={2}>
134
- <Label as="label" size={1} htmlFor={teamId_}>
135
- Team ID <span style={{ opacity: 0.5, fontWeight: 'normal' }}>— optional</span>
136
- </Label>
137
- <TextInput
138
- id={teamId_}
139
- aria-describedby={teamHelpId}
140
- value={teamId}
141
- onChange={e => setTeamId((e.target as HTMLInputElement).value)}
142
- placeholder="team_xxxxxxxx"
143
- />
144
- <Text id={teamHelpId} size={0} muted>
145
- Required for team-owned Vercel projects. Find it at Vercel → Settings → General → Team ID (starts with <code>team_</code>).
146
- </Text>
147
- </Stack>
148
-
149
- {/* Disable delete */}
150
- <Flex align="center" gap={3}>
151
- <Switch
152
- checked={disableDelete}
153
- onChange={e => setDisableDelete((e.target as HTMLInputElement).checked)}
154
- id={disableId}
155
- />
156
- <Stack space={1}>
157
- <Label as="label" size={1} htmlFor={disableId}>Disable delete action</Label>
158
- <Text size={0} muted>Hides the delete button for this target in the studio.</Text>
159
- </Stack>
160
- </Flex>
161
-
162
- {error && (
163
- <Card tone="critical" padding={3} radius={2}>
164
- <Text size={1}>{error}</Text>
165
- </Card>
166
- )}
167
-
168
- </Stack>
169
- </Box>
170
- </Dialog>
171
- )
172
- }
@@ -1,318 +0,0 @@
1
- // Main deploy tool — fetches targets + token, renders per-project cards
2
- import { useState, useEffect, useCallback } from 'react'
3
- import { useClient } from 'sanity'
4
- import { TokenIcon, TrashIcon, WarningOutlineIcon, AddIcon } from '../icons'
5
- import { DeployItem } from './DeployItem'
6
- import { TokenSetup } from './TokenSetup'
7
- import { DeployTargetForm } from './DeployTargetForm'
8
- import { VERSION } from '../version'
9
- import type { DeployTarget } from '../types'
10
- import { Box, Button, Card, Dialog, Flex, Heading, Spinner, Stack, Text, ToastViewport, useToast } from '../compat'
11
-
12
- const TOKEN_QUERY = `*[_id == "config.vercelDeploy"][0].accessToken`
13
- // `useClient` hands back a raw-perspective client, so drafts are returned alongside published
14
- // documents. `vercel_deploy` is a registered type and therefore editable from Structure, which
15
- // would otherwise render two cards for the same target.
16
- const TARGETS_QUERY = `*[_type == "vercel_deploy" && !(_id in path("drafts.**"))] | order(_createdAt asc)`
17
-
18
- export function DeployTool() {
19
- const client = useClient({ apiVersion: '2025-01-01' })
20
- const toast = useToast()
21
-
22
- const [token, setToken] = useState<string | null>(null)
23
- const [targets, setTargets] = useState<DeployTarget[]>([])
24
- const [loading, setLoading] = useState(true)
25
- const [showTokenSetup, setShowTokenSetup] = useState(false)
26
- const [showCreateForm, setShowCreateForm] = useState(false)
27
- const [pendingEdit, setPendingEdit] = useState<DeployTarget | null>(null)
28
- const [pendingDelete, setPendingDelete] = useState<DeployTarget | null>(null)
29
- const [deleting, setDeleting] = useState(false)
30
-
31
- // ── Fetch token + targets ─────────────────────────────────────────────────
32
- const load = useCallback(async () => {
33
- setLoading(true)
34
- try {
35
- const [fetchedToken, fetchedTargets] = await Promise.all([
36
- client.fetch<string | null>(TOKEN_QUERY),
37
- client.fetch<DeployTarget[]>(TARGETS_QUERY),
38
- ])
39
- setToken(fetchedToken ?? null)
40
- setTargets(fetchedTargets)
41
- } catch (err) {
42
- console.error('deploy-vercel-from-sanity: load error', err)
43
- } finally {
44
- setLoading(false)
45
- }
46
- }, [client])
47
-
48
- useEffect(() => { load() }, [load])
49
-
50
- // Inject responsive styles once on mount
51
- useEffect(() => {
52
- // Reference-counted: with two tools mounted, the one that skipped creation must not
53
- // remove the stylesheet the other is still using.
54
- const existing = document.getElementById('dvfs-styles')
55
- if (existing) {
56
- const n = Number(existing.dataset.refs ?? '1') + 1
57
- existing.dataset.refs = String(n)
58
- return () => {
59
- const left = Number(existing.dataset.refs ?? '1') - 1
60
- existing.dataset.refs = String(left)
61
- if (left <= 0) existing.remove()
62
- }
63
- }
64
- const style = document.createElement('style')
65
- style.id = 'dvfs-styles'
66
- style.textContent = `
67
- @media (max-width: 768px) {
68
- .dvfs-header { flex-wrap: wrap !important; row-gap: 8px !important; }
69
- .dvfs-header-actions { width: 100% !important; flex-wrap: wrap !important; justify-content: flex-start !important; }
70
- .dvfs-grid { grid-template-columns: 1fr !important; }
71
- .dvfs-card-flex { flex-direction: column !important; }
72
- .dvfs-deploy-col { width: 100% !important; align-self: auto !important; }
73
- .dvfs-deploy-col button { border-radius: 3px !important; }
74
- }
75
- @keyframes dvfs-open {
76
- from { opacity: 0; transform: translateY(-4px); }
77
- to { opacity: 1; transform: translateY(0); }
78
- }
79
- .dvfs-accordion-content {
80
- animation: dvfs-open 0.15s ease-out;
81
- }
82
- `
83
- document.head.appendChild(style)
84
- style.dataset.refs = '1'
85
- return () => {
86
- const left = Number(style.dataset.refs ?? '1') - 1
87
- style.dataset.refs = String(left)
88
- if (left <= 0) style.remove()
89
- }
90
- }, [])
91
-
92
- // Live subscription — update targets when documents change
93
- useEffect(() => {
94
- const sub = client
95
- .listen<DeployTarget>(TARGETS_QUERY)
96
- .subscribe(() => {
97
- client.fetch<DeployTarget[]>(TARGETS_QUERY).then(setTargets).catch(err => {
98
- console.error('deploy-vercel-from-sanity: subscription refresh error', err)
99
- })
100
- })
101
- return () => sub.unsubscribe()
102
- }, [client])
103
-
104
- // ── Delete target ─────────────────────────────────────────────────────────
105
- const confirmDelete = useCallback(async () => {
106
- if (!pendingDelete) return
107
- setDeleting(true)
108
- try {
109
- // Remove the draft counterpart as well; deleting only the published id leaves the
110
- // target behind and it reappears on the next load.
111
- await client.delete(pendingDelete._id)
112
- await client.delete(`drafts.${pendingDelete._id}`).catch(() => {})
113
- setTargets(prev => prev.filter(t => t._id !== pendingDelete._id))
114
- toast.push({ status: 'success', title: `Deleted "${pendingDelete.name}"` })
115
- } catch (err) {
116
- toast.push({ status: 'error', title: 'Delete failed', description: String(err) })
117
- } finally {
118
- setDeleting(false)
119
- setPendingDelete(null)
120
- }
121
- }, [client, pendingDelete, toast])
122
-
123
- // ── Render ────────────────────────────────────────────────────────────────
124
- if (loading) {
125
- return (
126
- <Card height="fill" tone="transparent">
127
- <Flex align="center" justify="center" height="fill">
128
- <Spinner muted />
129
- </Flex>
130
- </Card>
131
- )
132
- }
133
-
134
- return (
135
- <Card height="fill" tone="transparent">
136
- <Box padding={5}>
137
- <Stack space={5}>
138
-
139
- {/* ── Header ──────────────────────────────────────────────── */}
140
- <Flex align="center" justify="space-between" className="dvfs-header">
141
- <Heading size={2}>Deploy with Vercel</Heading>
142
- <Flex align="center" gap={3} className="dvfs-header-actions">
143
- <Button
144
- text={token ? 'Token connected' : 'Connect API token'}
145
- mode="ghost"
146
- icon={TokenIcon}
147
- fontSize={1}
148
- tone={token ? 'positive' : 'caution'}
149
- onClick={() => setShowTokenSetup(true)}
150
- style={{ cursor: 'pointer' }}
151
- />
152
- <Button
153
- text="Add target"
154
- mode="ghost"
155
- icon={AddIcon}
156
- fontSize={1}
157
- onClick={() => setShowCreateForm(true)}
158
- style={{ cursor: 'pointer' }}
159
- />
160
- </Flex>
161
- </Flex>
162
-
163
- {/* ── No-token upgrade banner ──────────────────────────────── */}
164
- {!token && (
165
- <Card padding={4} radius={2} tone="caution" shadow={1}>
166
- <Flex align="center" justify="space-between" gap={4}>
167
- <Stack space={2}>
168
- <Text size={1} weight="semibold">Deploy status is not connected</Text>
169
- <Text size={1} muted>
170
- You can trigger deploys now. Connect a Vercel API token to also see
171
- deployment status, build logs, history, and commit metadata.
172
- </Text>
173
- </Stack>
174
- <Button
175
- text="Connect"
176
- tone="caution"
177
- fontSize={1}
178
- onClick={() => setShowTokenSetup(true)}
179
- style={{ cursor: 'pointer', flexShrink: 0 }}
180
- />
181
- </Flex>
182
- </Card>
183
- )}
184
-
185
- {/* ── No targets ──────────────────────────────────────────── */}
186
- {targets.length === 0 && (
187
- <Card padding={5} radius={2} tone="transparent" shadow={1}>
188
- <Stack space={4} style={{ textAlign: 'center' }}>
189
- <Text size={2} weight="semibold">No deploy targets configured</Text>
190
- <Text size={1} muted>
191
- Add a deploy target using the button above, or create a{' '}
192
- <code>vercel_deploy</code> document directly in the dataset.
193
- </Text>
194
- <Flex justify="center">
195
- <Button
196
- text="Add deploy target"
197
- tone="primary"
198
- icon={AddIcon}
199
- onClick={() => setShowCreateForm(true)}
200
- style={{ cursor: 'pointer' }}
201
- />
202
- </Flex>
203
- </Stack>
204
- </Card>
205
- )}
206
-
207
- {/* ── Deploy targets — responsive 2-col grid ──────────────── */}
208
- {targets.length > 0 && (
209
- <div className="dvfs-grid" style={{
210
- display: 'grid',
211
- gridTemplateColumns: 'repeat(auto-fill, minmax(min(540px, 100%), 1fr))',
212
- gap: '16px',
213
- alignItems: 'start',
214
- }}>
215
- {targets.map(target => (
216
- <DeployItem
217
- key={target._id}
218
- target={target}
219
- token={token ?? ''}
220
- onDelete={setPendingDelete}
221
- onEdit={setPendingEdit}
222
- />
223
- ))}
224
- </div>
225
- )}
226
-
227
- </Stack>
228
- </Box>
229
-
230
- {/* ── Version watermark ──────────────────────────────────────────── */}
231
- <Box
232
- style={{
233
- position: 'fixed',
234
- bottom: 12,
235
- right: 16,
236
- opacity: 0.25,
237
- pointerEvents: 'none',
238
- userSelect: 'none',
239
- }}
240
- >
241
- <Text size={0} muted>v{VERSION}</Text>
242
- </Box>
243
-
244
- {/* ── Token setup dialog ──────────────────────────────────────────── */}
245
- {/* The token dialog is always dismissable. Withholding Cancel on first run —
246
- exactly when the user may not have a token to hand — made it a keyboard trap,
247
- since Sanity's Dialog holds focus and Escape was wired to the absent handler. */}
248
- {showTokenSetup && (
249
- <TokenSetup
250
- onSaved={() => { setShowTokenSetup(false); load() }}
251
- onCancel={() => setShowTokenSetup(false)}
252
- />
253
- )}
254
-
255
- {/* ── Create form ─────────────────────────────────────────────────── */}
256
- {showCreateForm && (
257
- <DeployTargetForm
258
- onSaved={() => { setShowCreateForm(false); toast.push({ status: 'success', title: 'Deploy target added' }) }}
259
- onClose={() => setShowCreateForm(false)}
260
- />
261
- )}
262
-
263
- {/* ── Edit form ───────────────────────────────────────────────────── */}
264
- {pendingEdit && (
265
- <DeployTargetForm
266
- initial={pendingEdit}
267
- onSaved={() => { setPendingEdit(null); toast.push({ status: 'success', title: 'Deploy target updated' }) }}
268
- onClose={() => setPendingEdit(null)}
269
- />
270
- )}
271
-
272
- {/* ── Delete confirmation ─────────────────────────────────────────── */}
273
- {pendingDelete && (
274
- <Dialog
275
- header="Delete deploy target?"
276
- id="confirm-delete"
277
- onClose={() => setPendingDelete(null)}
278
- width={1}
279
- footer={
280
- <Flex padding={3} gap={2} justify="flex-end">
281
- <Button
282
- text="Cancel"
283
- mode="ghost"
284
- onClick={() => setPendingDelete(null)}
285
- style={{ cursor: 'pointer' }}
286
- />
287
- <Button
288
- text="Delete"
289
- tone="critical"
290
- icon={TrashIcon}
291
- loading={deleting}
292
- disabled={deleting}
293
- onClick={confirmDelete}
294
- style={{ cursor: 'pointer' }}
295
- />
296
- </Flex>
297
- }
298
- >
299
- <Box padding={4}>
300
- <Stack space={3}>
301
- <Flex align="center" gap={2}>
302
- <WarningOutlineIcon />
303
- <Text size={2} weight="semibold">{pendingDelete.name}</Text>
304
- </Flex>
305
- <Text size={1} muted>
306
- This removes the deploy target from the dataset. The Vercel deploy hook
307
- itself is not affected.
308
- </Text>
309
- </Stack>
310
- </Box>
311
- </Dialog>
312
- )}
313
-
314
- {/* Fallback toast surface — renders nothing when @sanity/ui exports its own useToast */}
315
- <ToastViewport />
316
- </Card>
317
- )
318
- }
@@ -1,23 +0,0 @@
1
- // Status badge for a Vercel deployment state
2
- import { stateLabel } from '../lib/helpers'
3
- import type { VercelDeployState } from '../types'
4
- import { Badge, Flex, Spinner } from '../compat'
5
-
6
- interface StatusBadgeProps {
7
- state: VercelDeployState | undefined
8
- /** When true, shows a spinner alongside the label */
9
- showSpinner?: boolean
10
- }
11
-
12
- export function StatusBadge({ state, showSpinner }: StatusBadgeProps) {
13
- const { label, tone } = stateLabel(state)
14
- const spinning = showSpinner && (state === 'QUEUED' || state === 'INITIALIZING' || state === 'BUILDING')
15
- return (
16
- <Flex align="center" gap={2}>
17
- {spinning && <Spinner muted />}
18
- <Badge tone={tone} padding={2}>
19
- {label}
20
- </Badge>
21
- </Flex>
22
- )
23
- }
@@ -1,98 +0,0 @@
1
- // Vercel API token form — rendered inside a Dialog by DeployTool
2
- import { useId, useState, useCallback } from 'react'
3
- import { useClient } from 'sanity'
4
- import { CheckmarkCircleIcon } from '../icons'
5
- import { Button, Card, Dialog, Flex, Label, Stack, Text, TextInput } from '../compat'
6
-
7
- interface TokenSetupProps {
8
- /** Called after the token is successfully saved */
9
- onSaved: () => void
10
- /** Called when the user dismisses — only available when a token already exists */
11
- onCancel?: () => void
12
- }
13
-
14
- const TOKEN_DOC_ID = 'config.vercelDeploy'
15
-
16
- export function TokenSetup({ onSaved, onCancel }: TokenSetupProps) {
17
- const tokenId = useId()
18
- const client = useClient({ apiVersion: '2025-01-01' })
19
- const [token, setToken] = useState('')
20
- const [saving, setSaving] = useState(false)
21
- const [error, setError] = useState<string | null>(null)
22
-
23
- const save = useCallback(async () => {
24
- if (!token.trim()) return
25
- setSaving(true)
26
- setError(null)
27
- try {
28
- await client.createOrReplace({
29
- _id: TOKEN_DOC_ID,
30
- _type: 'vercelDeploy.config',
31
- accessToken: token.trim(),
32
- })
33
- onSaved()
34
- } catch (err) {
35
- setError(err instanceof Error ? err.message : 'Failed to save token')
36
- } finally {
37
- setSaving(false)
38
- }
39
- }, [client, token, onSaved])
40
-
41
- return (
42
- <Dialog
43
- header="Connect Vercel API token"
44
- id="token-setup"
45
- onClose={onCancel}
46
- width={1}
47
- footer={
48
- <Flex padding={3} gap={2} justify="flex-end">
49
- {onCancel && (
50
- <Button text="Cancel" mode="ghost" onClick={onCancel} style={{ cursor: 'pointer' }} />
51
- )}
52
- <Button
53
- text="Save and connect"
54
- tone="primary"
55
- icon={CheckmarkCircleIcon}
56
- loading={saving}
57
- disabled={!token.trim() || saving}
58
- onClick={save}
59
- style={{ cursor: 'pointer' }}
60
- />
61
- </Flex>
62
- }
63
- >
64
- <Stack space={4} padding={4}>
65
- <Stack space={3}>
66
- <Text size={1} muted>
67
- A Vercel API token lets this tool read deployment status, history, build logs,
68
- and branch metadata. Without it you can still trigger deploys — you just won't
69
- see any feedback.
70
- </Text>
71
- <Text size={1} muted>
72
- Create one at <strong>vercel.com → Settings → Tokens</strong> with{' '}
73
- <strong>Full Account</strong> scope. The token is stored in your Sanity dataset
74
- and shared across all authenticated studio users.
75
- </Text>
76
- </Stack>
77
-
78
- <Stack space={2}>
79
- {/* A plain Text is not a label — @sanity/ui's Label needs `as="label"` to render one. */}
80
- <Label as="label" size={1} htmlFor={tokenId}>Vercel API Token</Label>
81
- <TextInput
82
- id={tokenId}
83
- value={token}
84
- onChange={e => setToken((e.target as HTMLInputElement).value)}
85
- placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
86
- type="password"
87
- />
88
- </Stack>
89
-
90
- {error && (
91
- <Card tone="critical" padding={3} radius={2} role="alert">
92
- <Text size={1}>{error}</Text>
93
- </Card>
94
- )}
95
- </Stack>
96
- </Dialog>
97
- )
98
- }
package/src/icons.tsx DELETED
@@ -1,78 +0,0 @@
1
- // Version-agnostic access to @sanity/icons — named exports on v2/v3/v4, <Icon symbol> on v5+
2
- import { forwardRef } from 'react'
3
- import type { ComponentType, SVGProps } from 'react'
4
- import { ICONS, resolveExport } from './compat/resolve'
5
-
6
- /** Props every Sanity icon accepts — it renders a plain sized SVG. */
7
- export type IconProps = SVGProps<SVGSVGElement>
8
-
9
- /** An icon component, whichever shape the installed @sanity/icons exposes it in. */
10
- export type IconComponent = ComponentType<IconProps>
11
-
12
- /** Sizing of a Sanity icon glyph — 1em square on a 25-unit viewBox, matching @sanity/icons. */
13
- const GLYPH = { width: '1em', height: '1em', viewBox: '0 0 25 25', fill: 'none' } as const
14
-
15
- /**
16
- * Placeholder for when the installed @sanity/icons exposes neither shape. Holds
17
- * layout, draws nothing, and is hidden from assistive tech since every icon in
18
- * this plugin is decorative.
19
- */
20
- const MissingIcon = forwardRef<SVGSVGElement, IconProps>(function MissingIcon(props, ref) {
21
- return <svg {...GLYPH} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" {...props} ref={ref} />
22
- })
23
-
24
- /** The v5+ `<Icon>` component, absent on icons v2, v3 and v4. */
25
- type SymbolIcon = ComponentType<IconProps & { symbol: string }>
26
-
27
- /**
28
- * The symbol map v5+ exposes alongside `Icon`. Used to check a symbol exists
29
- * before relying on it: `<Icon>` renders `null` for an unrecognised symbol, so
30
- * a renamed glyph would otherwise vanish silently rather than reaching
31
- * {@link MissingIcon}.
32
- */
33
- const SYMBOL_MAP = resolveExport<Record<string, unknown>>(ICONS, 'icons')
34
-
35
- /**
36
- * Resolve one glyph against whichever @sanity/icons the host Studio installed.
37
- * Always reads from the host package, so new and revised artwork is picked up on
38
- * the consumer's next @sanity/icons update without a release here.
39
- *
40
- * Note the named exports are not merely absent on v5 — they are declared `never`
41
- * with a deprecation note pointing at per-icon subpaths, so a static named import
42
- * is a type error as well as a runtime one. Subpaths only exist from v4.1.0,
43
- * which is why this resolves at runtime instead.
44
- *
45
- * @param name Named export used by icons v2, v3 and v4, e.g. `RocketIcon`.
46
- * @param symbol Kebab-case symbol used by the v5+ `<Icon>` component, e.g. `rocket`.
47
- */
48
- function resolveIcon(name: string, symbol: string): IconComponent {
49
- const named = resolveExport<IconComponent>(ICONS, name)
50
- if (named) return named
51
-
52
- const Icon = resolveExport<SymbolIcon>(ICONS, 'Icon')
53
- if (!Icon) return MissingIcon
54
- // An unknown symbol would render nothing at all; fall back to the sized placeholder instead.
55
- if (SYMBOL_MAP && !(symbol in SYMBOL_MAP)) return MissingIcon
56
-
57
- const Resolved = forwardRef<SVGSVGElement, IconProps>(function SanityIcon(props, ref) {
58
- return <Icon symbol={symbol} {...props} ref={ref} />
59
- })
60
- Resolved.displayName = name
61
- return Resolved
62
- }
63
-
64
- export const AddIcon = resolveIcon('AddIcon', 'add')
65
- export const CheckmarkCircleIcon = resolveIcon('CheckmarkCircleIcon', 'checkmark-circle')
66
- export const CheckmarkIcon = resolveIcon('CheckmarkIcon', 'checkmark')
67
- export const ChevronDownIcon = resolveIcon('ChevronDownIcon', 'chevron-down')
68
- export const ChevronUpIcon = resolveIcon('ChevronUpIcon', 'chevron-up')
69
- export const ClockIcon = resolveIcon('ClockIcon', 'clock')
70
- export const CloseIcon = resolveIcon('CloseIcon', 'close')
71
- export const CopyIcon = resolveIcon('CopyIcon', 'copy')
72
- export const EditIcon = resolveIcon('EditIcon', 'edit')
73
- export const EllipsisVerticalIcon = resolveIcon('EllipsisVerticalIcon', 'ellipsis-vertical')
74
- export const LaunchIcon = resolveIcon('LaunchIcon', 'launch')
75
- export const RocketIcon = resolveIcon('RocketIcon', 'rocket')
76
- export const TokenIcon = resolveIcon('TokenIcon', 'token')
77
- export const TrashIcon = resolveIcon('TrashIcon', 'trash')
78
- export const WarningOutlineIcon = resolveIcon('WarningOutlineIcon', 'warning-outline')