@liiift-studio/deploy-vercel-from-sanity 1.1.0 → 1.2.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/src/lib/api.ts CHANGED
@@ -55,10 +55,16 @@ export async function cancelDeployment(opts: {
55
55
  token: string
56
56
  teamId?: string
57
57
  }): Promise<void> {
58
- const params = opts.teamId ? `?teamId=${opts.teamId}` : ''
59
- await vercelFetch(`/v12/deployments/${opts.deploymentId}/cancel${params}`, opts.token, {
60
- method: 'PATCH',
61
- })
58
+ // teamId is free text from the target document — encode it rather than splicing it in raw,
59
+ // or an '&' injects extra parameters into an authenticated request.
60
+ const params = new URLSearchParams()
61
+ if (opts.teamId) params.set('teamId', opts.teamId)
62
+ const query = params.toString() ? `?${params}` : ''
63
+ await vercelFetch(
64
+ `/v12/deployments/${encodeURIComponent(opts.deploymentId)}/cancel${query}`,
65
+ opts.token,
66
+ { method: 'PATCH' },
67
+ )
62
68
  }
63
69
 
64
70
  /**
@@ -63,6 +63,23 @@ export function safeHref(url: string | undefined): string | undefined {
63
63
  }
64
64
  }
65
65
 
66
+ /**
67
+ * Builds a validated https URL from a Vercel deployment hostname.
68
+ *
69
+ * The API returns a bare host, so concatenating it fixes only the scheme —
70
+ * a value like `@evil.com` yields `https://@evil.com`, which navigates to an
71
+ * attacker-chosen host. Round-tripping through URL and checking the host was
72
+ * not rewritten closes that.
73
+ *
74
+ * @param host Deployment hostname from the Vercel API, e.g. `my-app-abc123.vercel.app`.
75
+ */
76
+ export function deploymentHref(host: string | undefined): string | undefined {
77
+ if (!host) return undefined
78
+ // Reject anything carrying credentials, a scheme, a port or a path — a plain host only.
79
+ if (!/^[a-z0-9.-]+$/i.test(host)) return undefined
80
+ return safeHref(`https://${host}`)
81
+ }
82
+
66
83
  /** Truncates a commit SHA to 7 chars */
67
84
  export function shortSha(sha: string | undefined): string {
68
85
  return sha ? sha.slice(0, 7) : ''
@@ -0,0 +1,38 @@
1
+ // Eager rocket glyph for schema icons — Studio serialises these with renderToString, which cannot resolve a lazy component
2
+ import { forwardRef } from 'react'
3
+ import type { SVGProps } from 'react'
4
+
5
+ /**
6
+ * Static copy of Sanity's rocket glyph.
7
+ *
8
+ * Schema type icons are rendered through `renderToString` when the Studio
9
+ * publishes its schema to Canvas/Create. On @sanity/icons v5 the resolved icon is
10
+ * a `React.lazy` component behind Suspense, and a synchronous server render
11
+ * cannot resolve a dynamic import — so the published icon comes out blank. This
12
+ * eager component sidesteps that for the one place it matters. Artwork is MIT,
13
+ * from @sanity/icons.
14
+ */
15
+ export const SchemaRocketIcon = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
16
+ function SchemaRocketIcon(props, ref) {
17
+ return (
18
+ <svg
19
+ width="1em"
20
+ height="1em"
21
+ viewBox="0 0 25 25"
22
+ fill="none"
23
+ xmlns="http://www.w3.org/2000/svg"
24
+ aria-hidden="true"
25
+ focusable="false"
26
+ {...props}
27
+ ref={ref}
28
+ >
29
+ <path
30
+ d="M12.5 20.5L15.5 14M11 9.49999L4.5 12.5M9 14C9 14 7.54688 14.9531 6.5 16C5.5 17 4.5 20.5 4.5 20.5C4.5 20.5 8 19.5 9 18.5C10 17.5 11 16 11 16M9 14C9 14 10.1 9.9 12.5 7.5C15.5 4.5 20.5 4.5 20.5 4.5C20.5 4.5 20.5 9.5 17.5 12.5C15.7492 14.2508 11 16 11 16L9 14ZM16.5 9.99999C16.5 10.8284 15.8284 11.5 15 11.5C14.1716 11.5 13.5 10.8284 13.5 9.99999C13.5 9.17157 14.1716 8.49999 15 8.49999C15.8284 8.49999 16.5 9.17157 16.5 9.99999Z"
31
+ stroke="currentColor"
32
+ strokeWidth={1.2}
33
+ strokeLinejoin="round"
34
+ />
35
+ </svg>
36
+ )
37
+ },
38
+ )
@@ -0,0 +1,34 @@
1
+ // Sanity schema for the vercelDeploy.config singleton that holds the Vercel API token
2
+ import { defineField, defineType } from 'sanity'
3
+
4
+ /**
5
+ * Registers the document the plugin stores its Vercel token in.
6
+ *
7
+ * The document was previously written without a registered type, which made it
8
+ * invisible to Structure, absent from schema extraction, and impossible to
9
+ * inspect or revoke from inside the Studio. Registering it does not change where
10
+ * the token lives — see the security note in the README about dataset read
11
+ * access — but it does make the credential visible and removable.
12
+ */
13
+ export const vercelConfigSchema = defineType({
14
+ name: 'vercelDeploy.config',
15
+ title: 'Vercel Deploy Configuration',
16
+ type: 'document',
17
+ fields: [
18
+ defineField({
19
+ name: 'accessToken',
20
+ title: 'Vercel API Token',
21
+ type: 'string',
22
+ description:
23
+ 'Readable by anyone who can read this dataset. Delete this document to revoke the stored token.',
24
+ }),
25
+ ],
26
+ preview: {
27
+ select: { token: 'accessToken' },
28
+ prepare: ({ token }: { token?: string }) => ({
29
+ title: 'Vercel API Token',
30
+ // Never render the secret itself in a preview.
31
+ subtitle: token ? 'Connected' : 'Not set',
32
+ }),
33
+ },
34
+ })
@@ -1,12 +1,12 @@
1
1
  // Sanity schema for vercel_deploy documents — stores deploy hook targets
2
2
  import { defineField, defineType } from 'sanity'
3
- import { RocketIcon } from '../icons'
3
+ import { SchemaRocketIcon } from './schemaIcon'
4
4
 
5
5
  export const vercelDeploySchema = defineType({
6
6
  name: 'vercel_deploy',
7
7
  title: 'Deploy Target',
8
8
  type: 'document',
9
- icon: RocketIcon,
9
+ icon: SchemaRocketIcon,
10
10
  fields: [
11
11
  defineField({
12
12
  name: 'name',
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Package version — keep in sync with package.json
2
- export const VERSION = '1.1.0'
2
+ export const VERSION = '1.2.0'
package/src/ui.tsx DELETED
@@ -1,337 +0,0 @@
1
- // Version-agnostic access to the @sanity/ui components that v4 moved out of the barrel, with local fallbacks
2
- import { useCallback, useEffect, useRef, useState } from 'react'
3
- import type { CSSProperties, ReactNode } from 'react'
4
- import * as sanityUi from '@sanity/ui'
5
- import { Box, Button, Card, Flex, Stack as SanityStack, Text } from '@sanity/ui'
6
-
7
- /**
8
- * The installed @sanity/ui namespace, read through an index signature.
9
- *
10
- * @sanity/ui v4 relocated Tooltip, Menu, MenuButton, MenuItem, Code, Popover and
11
- * useToast into subpath entry points (`@sanity/ui/tooltip`, `/menu`, …) and dropped
12
- * them from the root barrel. Those subpaths do not exist on v2 or v3, so a static
13
- * import of either shape breaks half the supported Studio range. Reading the barrel
14
- * dynamically lets one build prefer the real components wherever they are still
15
- * exported and fall back to the local equivalents below when they are not.
16
- */
17
- const INSTALLED = sanityUi as unknown as Record<string, unknown>
18
-
19
- /* ------------------------------------------------------------------ stack -- */
20
-
21
- /**
22
- * Whether the installed @sanity/ui is v4 or newer.
23
- *
24
- * v4 both emptied the barrel of Tooltip/Menu/Code/useToast and renamed Stack's
25
- * `space` prop to `gap` (the old name is typed `never` and ignored at runtime, so
26
- * passing it silently collapses all vertical spacing). Those landed in the same
27
- * major, so the absence of `useToast` from the barrel identifies v4+ reliably.
28
- */
29
- const IS_UI_V4_PLUS = !('useToast' in INSTALLED)
30
-
31
- /** Props for the compat Stack — keeps the pre-v4 `space` name at every call site. */
32
- export type StackProps = {
33
- space?: number
34
- children: ReactNode
35
- [key: string]: unknown
36
- }
37
-
38
- /**
39
- * Vertical stack. Forwards `space` on @sanity/ui v2 and v3 and `gap` on v4+, so one
40
- * call site spells spacing correctly on either major.
41
- *
42
- * @param space Spacing step on Sanity's scale, forwarded under whichever name applies.
43
- */
44
- export function Stack({ space, children, ...rest }: StackProps): React.JSX.Element {
45
- const spacing = space === undefined ? {} : IS_UI_V4_PLUS ? { gap: space } : { space }
46
- const Component = SanityStack as unknown as React.ComponentType<Record<string, unknown>>
47
- return <Component {...rest} {...spacing}>{children}</Component>
48
- }
49
-
50
- /* ------------------------------------------------------------------ toast -- */
51
-
52
- /** A toast request, matching the subset of @sanity/ui's ToastParams this plugin uses. */
53
- export type ToastParams = {
54
- status?: 'success' | 'error' | 'warning' | 'info'
55
- title?: ReactNode
56
- description?: ReactNode
57
- }
58
-
59
- /** The object returned by useToast — only `push` is used here. */
60
- export type Toaster = { push: (params: ToastParams) => void }
61
-
62
- /** A queued fallback toast. `id` is a monotonic counter, unique for the session. */
63
- type LocalToast = ToastParams & { id: number }
64
-
65
- /** How long a fallback toast stays on screen, in milliseconds. */
66
- const LOCAL_TOAST_MS = 5000
67
-
68
- /** Border tone per toast status, used only by the fallback viewport. */
69
- const LOCAL_TOAST_TONE: Record<string, 'positive' | 'critical' | 'caution' | 'primary'> = {
70
- success: 'positive',
71
- error: 'critical',
72
- warning: 'caution',
73
- info: 'primary',
74
- }
75
-
76
- let nextToastId = 0
77
- let localToasts: LocalToast[] = []
78
- const toastListeners = new Set<(toasts: LocalToast[]) => void>()
79
-
80
- /** Publish the current fallback queue to every mounted viewport. */
81
- function emitToasts(): void {
82
- for (const listener of toastListeners) listener(localToasts)
83
- }
84
-
85
- /** Queue a fallback toast and schedule its removal. */
86
- function pushLocalToast(params: ToastParams): void {
87
- const toast: LocalToast = { ...params, id: nextToastId++ }
88
- localToasts = [...localToasts, toast]
89
- emitToasts()
90
- setTimeout(() => {
91
- localToasts = localToasts.filter(t => t.id !== toast.id)
92
- emitToasts()
93
- }, LOCAL_TOAST_MS)
94
- }
95
-
96
- /** The real hook when the installed @sanity/ui still exports it, otherwise undefined. */
97
- const installedUseToast = INSTALLED.useToast as (() => Toaster) | undefined
98
-
99
- /** Stable fallback toaster — identity never changes, so it is safe in dependency arrays. */
100
- const localToaster: Toaster = { push: pushLocalToast }
101
-
102
- /**
103
- * Push toasts through Studio's toast system where available, or through the local
104
- * viewport below on @sanity/ui v4+. Mount `<ToastViewport />` once for the fallback
105
- * to be visible; it renders nothing when the real hook is present.
106
- */
107
- export function useToast(): Toaster {
108
- const real = installedUseToast
109
- // Hook order is stable across renders because `installedUseToast` is module-level.
110
- if (real) return real()
111
- return localToaster
112
- }
113
-
114
- /**
115
- * Renders queued fallback toasts. No-op when the installed @sanity/ui exports its
116
- * own useToast, because Studio's ToastProvider is already handling them.
117
- */
118
- export function ToastViewport(): React.JSX.Element | null {
119
- const [toasts, setToasts] = useState<LocalToast[]>(localToasts)
120
-
121
- useEffect(() => {
122
- if (installedUseToast) return
123
- toastListeners.add(setToasts)
124
- return () => { toastListeners.delete(setToasts) }
125
- }, [])
126
-
127
- if (installedUseToast || toasts.length === 0) return null
128
-
129
- return (
130
- <Box style={{ position: 'fixed', bottom: 16, right: 16, zIndex: 1000000, maxWidth: 360 }}>
131
- <Stack space={2}>
132
- {toasts.map(toast => (
133
- <Card
134
- key={toast.id}
135
- padding={3}
136
- radius={2}
137
- shadow={3}
138
- tone={LOCAL_TOAST_TONE[toast.status ?? 'info'] ?? 'primary'}
139
- >
140
- <Stack space={2}>
141
- {toast.title && <Text size={1} weight="semibold">{toast.title}</Text>}
142
- {toast.description && <Text size={1} muted>{toast.description}</Text>}
143
- </Stack>
144
- </Card>
145
- ))}
146
- </Stack>
147
- </Box>
148
- )
149
- }
150
-
151
- /* ---------------------------------------------------------------- tooltip -- */
152
-
153
- /** Props for the compat Tooltip — plain text rather than @sanity/ui's ReactNode `content`. */
154
- export type TooltipProps = { text: string; children: ReactNode }
155
-
156
- /** The real Tooltip when the installed @sanity/ui still exports it, otherwise undefined. */
157
- const InstalledTooltip = INSTALLED.Tooltip as
158
- | React.ComponentType<{ content: ReactNode; portal?: boolean; children: ReactNode }>
159
- | undefined
160
-
161
- /**
162
- * Hover hint over `children`. Uses Studio's Tooltip where available and degrades to
163
- * the native `title` attribute on @sanity/ui v4+.
164
- */
165
- export function Tooltip({ text, children }: TooltipProps): React.JSX.Element {
166
- if (InstalledTooltip) {
167
- return (
168
- <InstalledTooltip content={<Box padding={2}><Text size={1}>{text}</Text></Box>} portal>
169
- {children}
170
- </InstalledTooltip>
171
- )
172
- }
173
- return <span title={text} style={{ display: 'inline-flex' }}>{children}</span>
174
- }
175
-
176
- /* ------------------------------------------------------------------- menu -- */
177
-
178
- /** One entry in an ActionMenu. Either `onClick` or `href` drives the behaviour. */
179
- export type MenuAction = {
180
- text: string
181
- icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
182
- onClick?: () => void
183
- href?: string
184
- tone?: 'critical' | 'default'
185
- }
186
-
187
- /** Props for the compat ActionMenu — a declarative item list rather than nested JSX. */
188
- export type ActionMenuProps = { id: string; items: MenuAction[]; buttonIcon: MenuAction['icon'] }
189
-
190
- const InstalledMenuButton = INSTALLED.MenuButton as React.ComponentType<Record<string, unknown>> | undefined
191
- const InstalledMenu = INSTALLED.Menu as React.ComponentType<Record<string, unknown>> | undefined
192
- const InstalledMenuItem = INSTALLED.MenuItem as React.ComponentType<Record<string, unknown>> | undefined
193
-
194
- /** Whether the installed @sanity/ui still exports the full menu trio. */
195
- const HAS_INSTALLED_MENU = Boolean(InstalledMenuButton && InstalledMenu && InstalledMenuItem)
196
-
197
- /**
198
- * Overflow menu for a deploy target. Uses Studio's MenuButton where available and
199
- * falls back to a locally positioned card on @sanity/ui v4+.
200
- *
201
- * @param id Stable DOM id, required by @sanity/ui's MenuButton.
202
- * @param items Actions in display order.
203
- * @param buttonIcon Icon for the trigger button.
204
- */
205
- export function ActionMenu({ id, items, buttonIcon }: ActionMenuProps): React.JSX.Element {
206
- const [open, setOpen] = useState(false)
207
- const wrapRef = useRef<HTMLDivElement>(null)
208
-
209
- // Fallback only — dismiss on outside click or Escape.
210
- useEffect(() => {
211
- if (HAS_INSTALLED_MENU || !open) return
212
- const onPointerDown = (e: MouseEvent) => {
213
- if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
214
- }
215
- const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
216
- document.addEventListener('mousedown', onPointerDown)
217
- document.addEventListener('keydown', onKeyDown)
218
- return () => {
219
- document.removeEventListener('mousedown', onPointerDown)
220
- document.removeEventListener('keydown', onKeyDown)
221
- }
222
- }, [open])
223
-
224
- const runAction = useCallback((item: MenuAction) => {
225
- setOpen(false)
226
- item.onClick?.()
227
- }, [])
228
-
229
- if (HAS_INSTALLED_MENU && InstalledMenuButton && InstalledMenu && InstalledMenuItem) {
230
- const MenuButton = InstalledMenuButton
231
- const Menu = InstalledMenu
232
- const MenuItem = InstalledMenuItem
233
- return (
234
- <MenuButton
235
- id={id}
236
- button={<Button mode="ghost" icon={buttonIcon} padding={2} />}
237
- popover={{ placement: 'bottom-end' }}
238
- menu={
239
- <Menu>
240
- {items.map(item => (
241
- <MenuItem
242
- key={item.text}
243
- text={item.text}
244
- icon={item.icon}
245
- tone={item.tone}
246
- {...(item.href
247
- ? { as: 'a', href: item.href, target: '_blank', rel: 'noreferrer' }
248
- : { onClick: () => item.onClick?.() })}
249
- />
250
- ))}
251
- </Menu>
252
- }
253
- />
254
- )
255
- }
256
-
257
- return (
258
- <div ref={wrapRef} style={{ position: 'relative' }}>
259
- <Button
260
- mode="ghost"
261
- icon={buttonIcon}
262
- padding={2}
263
- id={id}
264
- aria-haspopup="menu"
265
- aria-expanded={open}
266
- onClick={() => setOpen(o => !o)}
267
- />
268
- {open && (
269
- <Card
270
- radius={2}
271
- shadow={3}
272
- padding={1}
273
- role="menu"
274
- style={{ position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 1000, minWidth: 180 }}
275
- >
276
- <Stack space={1}>
277
- {items.map(item => {
278
- const Icon = item.icon
279
- const label = (
280
- <Flex align="center" gap={2} paddingX={2} paddingY={2}>
281
- <Icon width="1em" height="1em" />
282
- <Text size={1}>{item.text}</Text>
283
- </Flex>
284
- )
285
- return item.href ? (
286
- <a
287
- key={item.text}
288
- role="menuitem"
289
- href={item.href}
290
- target="_blank"
291
- rel="noreferrer"
292
- onClick={() => setOpen(false)}
293
- style={{ color: 'inherit', textDecoration: 'none', display: 'block' }}
294
- >
295
- {label}
296
- </a>
297
- ) : (
298
- <Card
299
- key={item.text}
300
- as="button"
301
- role="menuitem"
302
- tone={item.tone === 'critical' ? 'critical' : 'default'}
303
- radius={1}
304
- onClick={() => runAction(item)}
305
- style={{ display: 'block', width: '100%', textAlign: 'left', cursor: 'pointer', border: 0, background: 'none' }}
306
- >
307
- {label}
308
- </Card>
309
- )
310
- })}
311
- </Stack>
312
- </Card>
313
- )}
314
- </div>
315
- )
316
- }
317
-
318
- /* ------------------------------------------------------------------- code -- */
319
-
320
- /** The real Code when the installed @sanity/ui still exports it, otherwise undefined. */
321
- const InstalledCode = INSTALLED.Code as
322
- | React.ComponentType<{ size?: number; style?: CSSProperties; children: ReactNode }>
323
- | undefined
324
-
325
- /** Monospace styling for the fallback Code element, approximating @sanity/ui's `size={1}`. */
326
- const FALLBACK_CODE_STYLE: CSSProperties = {
327
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
328
- fontSize: '0.8125rem',
329
- lineHeight: 1.4,
330
- margin: 0,
331
- }
332
-
333
- /** Monospace block. Uses Studio's Code where available, a plain `<code>` on @sanity/ui v4+. */
334
- export function Code({ style, children }: { style?: CSSProperties; children: ReactNode }): React.JSX.Element {
335
- if (InstalledCode) return <InstalledCode size={1} style={style}>{children}</InstalledCode>
336
- return <code style={{ ...FALLBACK_CODE_STYLE, ...style }}>{children}</code>
337
- }