@liiift-studio/deploy-vercel-from-sanity 1.2.0 → 1.3.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/README.md +171 -37
- package/dist/index.d.mts +54 -36
- package/dist/index.d.ts +54 -36
- package/dist/index.js +687 -468
- package/dist/index.mjs +538 -318
- package/package.json +14 -9
- package/proxy/.env.example +40 -0
- package/proxy/README.md +228 -0
- package/proxy/core.ts +283 -0
- package/proxy/nextjs-app-router/route.ts +129 -0
- package/src/compat/code.tsx +0 -29
- package/src/compat/index.ts +0 -14
- package/src/compat/menu.tsx +0 -225
- package/src/compat/primitives.tsx +0 -93
- package/src/compat/resolve.ts +0 -55
- package/src/compat/toast.tsx +0 -173
- package/src/compat/tooltip.tsx +0 -78
- package/src/components/DeployHistory.tsx +0 -160
- package/src/components/DeployItem.tsx +0 -596
- package/src/components/DeployTargetForm.tsx +0 -172
- package/src/components/DeployTool.tsx +0 -318
- package/src/components/StatusBadge.tsx +0 -23
- package/src/components/TokenSetup.tsx +0 -98
- package/src/icons.tsx +0 -78
- package/src/index.ts +0 -47
- package/src/lib/api.ts +0 -102
- package/src/lib/helpers.ts +0 -131
- package/src/schema/schemaIcon.tsx +0 -38
- package/src/schema/vercelConfig.ts +0 -34
- package/src/schema/vercelDeploy.ts +0 -49
- package/src/types.ts +0 -77
- package/src/version.ts +0 -2
package/src/compat/toast.tsx
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
// Toast delivery — Studio's own toast system where available, a local live-region viewport otherwise
|
|
2
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
3
|
-
import type { ReactNode } from 'react'
|
|
4
|
-
import { UI, resolveExport } from './resolve'
|
|
5
|
-
import { Box, Button, Card, Flex, Stack, Text } from './primitives'
|
|
6
|
-
import { CloseIcon } from '../icons'
|
|
7
|
-
|
|
8
|
-
/** A toast request — the subset of @sanity/ui's ToastParams this plugin uses. */
|
|
9
|
-
export type ToastParams = {
|
|
10
|
-
status?: 'success' | 'error' | 'warning' | 'info'
|
|
11
|
-
title?: ReactNode
|
|
12
|
-
description?: ReactNode
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** The object returned by useToast. Only `push` is used here. */
|
|
16
|
-
export type Toaster = { push: (params: ToastParams) => void }
|
|
17
|
-
|
|
18
|
-
/** A queued fallback toast. `id` is a monotonic counter, unique for the session. */
|
|
19
|
-
type LocalToast = ToastParams & { id: number }
|
|
20
|
-
|
|
21
|
-
/** How long a non-error fallback toast stays on screen, in milliseconds. */
|
|
22
|
-
const LOCAL_TOAST_MS = 6000
|
|
23
|
-
|
|
24
|
-
/** Most toasts kept on screen at once; older ones are dropped so the column cannot grow past the viewport. */
|
|
25
|
-
const MAX_VISIBLE_TOASTS = 4
|
|
26
|
-
|
|
27
|
-
/** Stacking index for the fallback viewport. High enough to clear Studio chrome, since it is not in Sanity's Layer stack. */
|
|
28
|
-
const TOAST_Z_INDEX = 1000000
|
|
29
|
-
|
|
30
|
-
/** Card tone per toast status, used only by the fallback viewport. */
|
|
31
|
-
const LOCAL_TOAST_TONE: Record<string, 'positive' | 'critical' | 'caution' | 'primary'> = {
|
|
32
|
-
success: 'positive',
|
|
33
|
-
error: 'critical',
|
|
34
|
-
warning: 'caution',
|
|
35
|
-
info: 'primary',
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Statuses that stay until dismissed — they carry text the user is expected to act on. */
|
|
39
|
-
const PERSISTENT_STATUSES = new Set(['error', 'warning'])
|
|
40
|
-
|
|
41
|
-
let nextToastId = 0
|
|
42
|
-
let localToasts: LocalToast[] = []
|
|
43
|
-
const toastListeners = new Set<(toasts: LocalToast[]) => void>()
|
|
44
|
-
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>()
|
|
45
|
-
|
|
46
|
-
/** Publish the current fallback queue to every mounted viewport. */
|
|
47
|
-
function emitToasts(): void {
|
|
48
|
-
for (const listener of toastListeners) listener(localToasts)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Remove one fallback toast and cancel its pending auto-dismiss. */
|
|
52
|
-
function removeLocalToast(id: number): void {
|
|
53
|
-
const timer = dismissTimers.get(id)
|
|
54
|
-
if (timer) {
|
|
55
|
-
clearTimeout(timer)
|
|
56
|
-
dismissTimers.delete(id)
|
|
57
|
-
}
|
|
58
|
-
localToasts = localToasts.filter(t => t.id !== id)
|
|
59
|
-
emitToasts()
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Start (or restart) the auto-dismiss countdown for one toast. Persistent statuses are left alone. */
|
|
63
|
-
function scheduleDismiss(id: number, status: ToastParams['status']): void {
|
|
64
|
-
if (PERSISTENT_STATUSES.has(status ?? 'info')) return
|
|
65
|
-
const existing = dismissTimers.get(id)
|
|
66
|
-
if (existing) clearTimeout(existing)
|
|
67
|
-
dismissTimers.set(id, setTimeout(() => removeLocalToast(id), LOCAL_TOAST_MS))
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Queue a fallback toast, trimming the oldest if the column is already full. */
|
|
71
|
-
function pushLocalToast(params: ToastParams): void {
|
|
72
|
-
const toast: LocalToast = { ...params, id: nextToastId++ }
|
|
73
|
-
localToasts = [...localToasts, toast].slice(-MAX_VISIBLE_TOASTS)
|
|
74
|
-
emitToasts()
|
|
75
|
-
scheduleDismiss(toast.id, toast.status)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The real hook when the installed @sanity/ui still exports it, otherwise undefined. */
|
|
79
|
-
const installedUseToast = resolveExport<() => Toaster>(UI, 'useToast')
|
|
80
|
-
|
|
81
|
-
/** Stable fallback toaster — identity never changes, so it is safe in dependency arrays. */
|
|
82
|
-
const localToaster: Toaster = { push: pushLocalToast }
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Push toasts through Studio's toast system where available, or through
|
|
86
|
-
* {@link ToastViewport} on @sanity/ui v4+.
|
|
87
|
-
*
|
|
88
|
-
* The branch is decided once at module load, so the hook-call order of any
|
|
89
|
-
* component using this is constant across renders.
|
|
90
|
-
*/
|
|
91
|
-
export function useToast(): Toaster {
|
|
92
|
-
const real = installedUseToast
|
|
93
|
-
if (real) return real()
|
|
94
|
-
return localToaster
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Live region for fallback toasts.
|
|
99
|
-
*
|
|
100
|
-
* The wrapper stays mounted and empty when there is nothing to show: assistive
|
|
101
|
-
* tech has to observe a live region *before* content lands in it, so a region
|
|
102
|
-
* created and populated in the same commit is routinely missed. Renders nothing
|
|
103
|
-
* at all when the installed @sanity/ui provides its own toast system.
|
|
104
|
-
*/
|
|
105
|
-
export function ToastViewport(): React.JSX.Element | null {
|
|
106
|
-
const [toasts, setToasts] = useState<LocalToast[]>(localToasts)
|
|
107
|
-
|
|
108
|
-
useEffect(() => {
|
|
109
|
-
if (installedUseToast) return
|
|
110
|
-
toastListeners.add(setToasts)
|
|
111
|
-
// Re-read after subscribing: a toast pushed between render and commit would otherwise be missed.
|
|
112
|
-
setToasts(localToasts)
|
|
113
|
-
return () => { toastListeners.delete(setToasts) }
|
|
114
|
-
}, [])
|
|
115
|
-
|
|
116
|
-
const hold = useCallback((id: number) => {
|
|
117
|
-
const timer = dismissTimers.get(id)
|
|
118
|
-
if (timer) {
|
|
119
|
-
clearTimeout(timer)
|
|
120
|
-
dismissTimers.delete(id)
|
|
121
|
-
}
|
|
122
|
-
}, [])
|
|
123
|
-
|
|
124
|
-
if (installedUseToast) return null
|
|
125
|
-
|
|
126
|
-
return (
|
|
127
|
-
<Box
|
|
128
|
-
role="status"
|
|
129
|
-
aria-live="polite"
|
|
130
|
-
aria-atomic={false}
|
|
131
|
-
// pointer-events is released so the fixed column cannot swallow clicks on the tool beneath it.
|
|
132
|
-
style={{
|
|
133
|
-
position: 'fixed',
|
|
134
|
-
bottom: 16,
|
|
135
|
-
right: 16,
|
|
136
|
-
zIndex: TOAST_Z_INDEX,
|
|
137
|
-
width: 'min(360px, calc(100vw - 32px))',
|
|
138
|
-
pointerEvents: 'none',
|
|
139
|
-
}}
|
|
140
|
-
>
|
|
141
|
-
<Stack space={2}>
|
|
142
|
-
{toasts.map(toast => (
|
|
143
|
-
<Card
|
|
144
|
-
key={toast.id}
|
|
145
|
-
padding={3}
|
|
146
|
-
radius={2}
|
|
147
|
-
shadow={3}
|
|
148
|
-
tone={LOCAL_TOAST_TONE[toast.status ?? 'info'] ?? 'primary'}
|
|
149
|
-
style={{ pointerEvents: 'auto' }}
|
|
150
|
-
onMouseEnter={() => hold(toast.id)}
|
|
151
|
-
onFocusCapture={() => hold(toast.id)}
|
|
152
|
-
onMouseLeave={() => scheduleDismiss(toast.id, toast.status)}
|
|
153
|
-
>
|
|
154
|
-
<Flex align="flex-start" gap={3}>
|
|
155
|
-
<Stack space={2} flex={1}>
|
|
156
|
-
{toast.title && <Text size={1} weight="semibold">{toast.title}</Text>}
|
|
157
|
-
{toast.description && <Text size={1} muted>{toast.description}</Text>}
|
|
158
|
-
</Stack>
|
|
159
|
-
<Button
|
|
160
|
-
mode="bleed"
|
|
161
|
-
padding={2}
|
|
162
|
-
icon={CloseIcon}
|
|
163
|
-
text=""
|
|
164
|
-
aria-label="Dismiss notification"
|
|
165
|
-
onClick={() => removeLocalToast(toast.id)}
|
|
166
|
-
/>
|
|
167
|
-
</Flex>
|
|
168
|
-
</Card>
|
|
169
|
-
))}
|
|
170
|
-
</Stack>
|
|
171
|
-
</Box>
|
|
172
|
-
)
|
|
173
|
-
}
|
package/src/compat/tooltip.tsx
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// Hover/focus hint — Studio's Tooltip where available, an accessible local tooltip otherwise
|
|
2
|
-
import { useId, useState } from 'react'
|
|
3
|
-
import type { ReactNode } from 'react'
|
|
4
|
-
import { UI, resolveExport } from './resolve'
|
|
5
|
-
import { Box, Card, Text } from './primitives'
|
|
6
|
-
import type { ComponentType } from 'react'
|
|
7
|
-
|
|
8
|
-
/** Props for the compat Tooltip — plain text rather than @sanity/ui's ReactNode `content`. */
|
|
9
|
-
export type TooltipProps = {
|
|
10
|
-
/** Hint text. Also used as the accessible description of the wrapped control. */
|
|
11
|
-
text: string
|
|
12
|
-
children: ReactNode
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** The real Tooltip when the installed @sanity/ui still exports it, otherwise undefined. */
|
|
16
|
-
const InstalledTooltip = resolveExport<ComponentType<{
|
|
17
|
-
content: ReactNode
|
|
18
|
-
portal?: boolean
|
|
19
|
-
children: ReactNode
|
|
20
|
-
}>>(UI, 'Tooltip')
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Hint shown on hover and on keyboard focus.
|
|
24
|
-
*
|
|
25
|
-
* The fallback deliberately does not use the native `title` attribute: `title`
|
|
26
|
-
* never appears on keyboard focus, never appears on touch, cannot be dismissed,
|
|
27
|
-
* and browsers will not re-read it while the pointer is stationary — which
|
|
28
|
-
* silently broke the copy button's "Copied!" confirmation. This renders a real
|
|
29
|
-
* element instead and wires it to the wrapped control via `aria-describedby`,
|
|
30
|
-
* so the text is announced and updates when it changes.
|
|
31
|
-
*/
|
|
32
|
-
export function Tooltip({ text, children }: TooltipProps): React.JSX.Element {
|
|
33
|
-
const id = useId()
|
|
34
|
-
const [visible, setVisible] = useState(false)
|
|
35
|
-
|
|
36
|
-
if (InstalledTooltip) {
|
|
37
|
-
return (
|
|
38
|
-
<InstalledTooltip content={<Box padding={2}><Text size={1}>{text}</Text></Box>} portal>
|
|
39
|
-
{children}
|
|
40
|
-
</InstalledTooltip>
|
|
41
|
-
)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return (
|
|
45
|
-
<span
|
|
46
|
-
style={{ position: 'relative', display: 'inline-flex' }}
|
|
47
|
-
aria-describedby={id}
|
|
48
|
-
onMouseEnter={() => setVisible(true)}
|
|
49
|
-
onMouseLeave={() => setVisible(false)}
|
|
50
|
-
onFocusCapture={() => setVisible(true)}
|
|
51
|
-
onBlurCapture={() => setVisible(false)}
|
|
52
|
-
onKeyDown={e => { if (e.key === 'Escape') setVisible(false) }}
|
|
53
|
-
>
|
|
54
|
-
{children}
|
|
55
|
-
<Card
|
|
56
|
-
id={id}
|
|
57
|
-
role="tooltip"
|
|
58
|
-
radius={2}
|
|
59
|
-
shadow={2}
|
|
60
|
-
padding={2}
|
|
61
|
-
style={{
|
|
62
|
-
position: 'absolute',
|
|
63
|
-
bottom: '100%',
|
|
64
|
-
left: '50%',
|
|
65
|
-
transform: 'translateX(-50%)',
|
|
66
|
-
marginBottom: 4,
|
|
67
|
-
whiteSpace: 'nowrap',
|
|
68
|
-
pointerEvents: 'none',
|
|
69
|
-
zIndex: 1000,
|
|
70
|
-
// Kept in the accessibility tree when hidden so `aria-describedby` still resolves.
|
|
71
|
-
visibility: visible ? 'visible' : 'hidden',
|
|
72
|
-
}}
|
|
73
|
-
>
|
|
74
|
-
<Text size={1}>{text}</Text>
|
|
75
|
-
</Card>
|
|
76
|
-
</span>
|
|
77
|
-
)
|
|
78
|
-
}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
// Deployment history modal — shows last 10 deployments for a target
|
|
2
|
-
import { useEffect, useState, useCallback } from 'react'
|
|
3
|
-
import { LaunchIcon, CloseIcon } from '../icons'
|
|
4
|
-
import { listDeployments } from '../lib/api'
|
|
5
|
-
import { parseHookUrl, stateLabel, timeAgo, shortSha, safeHref, deploymentHref } from '../lib/helpers'
|
|
6
|
-
import type { DeployTarget, VercelDeployment } from '../types'
|
|
7
|
-
import { Badge, Box, Button, Card, Dialog, Flex, Spinner, Stack, Text } from '../compat'
|
|
8
|
-
|
|
9
|
-
interface DeployHistoryProps {
|
|
10
|
-
target: DeployTarget
|
|
11
|
-
token: string
|
|
12
|
-
onClose: () => void
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function DeployHistory({ target, token, onClose }: DeployHistoryProps) {
|
|
16
|
-
const [deployments, setDeployments] = useState<VercelDeployment[]>([])
|
|
17
|
-
const [loading, setLoading] = useState(true)
|
|
18
|
-
const [error, setError] = useState<string | null>(null)
|
|
19
|
-
|
|
20
|
-
const { projectId, hookId } = parseHookUrl(target.url)
|
|
21
|
-
|
|
22
|
-
const load = useCallback(async () => {
|
|
23
|
-
setLoading(true)
|
|
24
|
-
setError(null)
|
|
25
|
-
try {
|
|
26
|
-
const data = await listDeployments({ projectId, hookId, token, teamId: target.teamId, limit: 10 })
|
|
27
|
-
setDeployments(data)
|
|
28
|
-
} catch (err) {
|
|
29
|
-
setError(err instanceof Error ? err.message : 'Failed to load history')
|
|
30
|
-
} finally {
|
|
31
|
-
setLoading(false)
|
|
32
|
-
}
|
|
33
|
-
}, [projectId, hookId, token, target.teamId])
|
|
34
|
-
|
|
35
|
-
useEffect(() => { load() }, [load])
|
|
36
|
-
|
|
37
|
-
return (
|
|
38
|
-
<Dialog
|
|
39
|
-
header={`${target.name} — Deployment History`}
|
|
40
|
-
id="deploy-history"
|
|
41
|
-
onClose={onClose}
|
|
42
|
-
width={2}
|
|
43
|
-
footer={
|
|
44
|
-
<Box padding={3}>
|
|
45
|
-
<Button text="Close" icon={CloseIcon} mode="ghost" onClick={onClose} />
|
|
46
|
-
</Box>
|
|
47
|
-
}
|
|
48
|
-
>
|
|
49
|
-
<Box padding={4}>
|
|
50
|
-
{loading && (
|
|
51
|
-
<Flex justify="center" padding={6}>
|
|
52
|
-
<Spinner muted />
|
|
53
|
-
</Flex>
|
|
54
|
-
)}
|
|
55
|
-
|
|
56
|
-
{error && (
|
|
57
|
-
<Card tone="critical" padding={4} radius={2}>
|
|
58
|
-
<Text size={1}>{error}</Text>
|
|
59
|
-
</Card>
|
|
60
|
-
)}
|
|
61
|
-
|
|
62
|
-
{!loading && !error && deployments.length === 0 && (
|
|
63
|
-
<Card tone="transparent" padding={4}>
|
|
64
|
-
<Text size={1} muted align="center">No deployments found for this hook.</Text>
|
|
65
|
-
</Card>
|
|
66
|
-
)}
|
|
67
|
-
|
|
68
|
-
{!loading && deployments.length > 0 && (
|
|
69
|
-
<Stack space={2}>
|
|
70
|
-
{/* Column headers */}
|
|
71
|
-
<Card padding={3} radius={2} tone="transparent">
|
|
72
|
-
<Flex gap={3}>
|
|
73
|
-
<Box flex={2}><Text size={0} weight="semibold" muted>Preview URL</Text></Box>
|
|
74
|
-
<Box flex={1}><Text size={0} weight="semibold" muted>Status</Text></Box>
|
|
75
|
-
<Box flex={2}><Text size={0} weight="semibold" muted>Branch · Commit</Text></Box>
|
|
76
|
-
<Box flex={1}><Text size={0} weight="semibold" muted>Deployed</Text></Box>
|
|
77
|
-
<Box style={{ width: 64 }}><Text size={0} weight="semibold" muted>Logs</Text></Box>
|
|
78
|
-
</Flex>
|
|
79
|
-
</Card>
|
|
80
|
-
|
|
81
|
-
{deployments.map(d => {
|
|
82
|
-
const { label, tone } = stateLabel(d.state)
|
|
83
|
-
const branch = d.meta?.githubCommitRef ?? '—'
|
|
84
|
-
const sha = shortSha(d.meta?.githubCommitSha)
|
|
85
|
-
const message = d.meta?.githubCommitMessage?.split('\n')[0] ?? ''
|
|
86
|
-
|
|
87
|
-
return (
|
|
88
|
-
<Card key={d.uid} padding={3} radius={2} shadow={1} tone="default">
|
|
89
|
-
<Flex gap={3} align="center">
|
|
90
|
-
{/* Preview URL */}
|
|
91
|
-
<Box flex={2} style={{ overflow: 'hidden' }}>
|
|
92
|
-
{deploymentHref(d.url) ? (
|
|
93
|
-
<a
|
|
94
|
-
href={deploymentHref(d.url)}
|
|
95
|
-
target="_blank"
|
|
96
|
-
rel="noreferrer"
|
|
97
|
-
style={{ color: 'inherit' }}
|
|
98
|
-
>
|
|
99
|
-
<Text size={1} style={{ textDecoration: 'underline' }}>
|
|
100
|
-
{d.url.length > 36 ? `${d.url.slice(0, 36)}…` : d.url}
|
|
101
|
-
</Text>
|
|
102
|
-
</a>
|
|
103
|
-
) : (
|
|
104
|
-
<Text size={1} muted>—</Text>
|
|
105
|
-
)}
|
|
106
|
-
</Box>
|
|
107
|
-
|
|
108
|
-
{/* Status */}
|
|
109
|
-
<Box flex={1}>
|
|
110
|
-
<Badge tone={tone}>{label}</Badge>
|
|
111
|
-
</Box>
|
|
112
|
-
|
|
113
|
-
{/* Branch + commit */}
|
|
114
|
-
<Box flex={2} style={{ overflow: 'hidden' }}>
|
|
115
|
-
<Stack space={1}>
|
|
116
|
-
<Text size={1}>{branch}</Text>
|
|
117
|
-
{sha && (
|
|
118
|
-
<Text size={0} muted>
|
|
119
|
-
{sha}{message ? ` · ${message.slice(0, 40)}${message.length > 40 ? '…' : ''}` : ''}
|
|
120
|
-
</Text>
|
|
121
|
-
)}
|
|
122
|
-
</Stack>
|
|
123
|
-
</Box>
|
|
124
|
-
|
|
125
|
-
{/* Time */}
|
|
126
|
-
<Box flex={1}>
|
|
127
|
-
<Text size={1} muted>{timeAgo(d.created)}</Text>
|
|
128
|
-
</Box>
|
|
129
|
-
|
|
130
|
-
{/* Build logs link. Rendered as a link, not a Button inside an anchor:
|
|
131
|
-
nesting interactive content in <a> gives two focus stops and Enter
|
|
132
|
-
activates the button, which has no handler, so the link never opens
|
|
133
|
-
for keyboard users. */}
|
|
134
|
-
<Box style={{ width: 64 }}>
|
|
135
|
-
{safeHref(d.inspectorUrl) ? (
|
|
136
|
-
<Button
|
|
137
|
-
as="a"
|
|
138
|
-
href={safeHref(d.inspectorUrl)}
|
|
139
|
-
target="_blank"
|
|
140
|
-
rel="noreferrer"
|
|
141
|
-
text="Logs"
|
|
142
|
-
mode="ghost"
|
|
143
|
-
tone="default"
|
|
144
|
-
icon={LaunchIcon}
|
|
145
|
-
style={{ fontSize: '12px' }}
|
|
146
|
-
/>
|
|
147
|
-
) : (
|
|
148
|
-
<Text size={1} muted>—</Text>
|
|
149
|
-
)}
|
|
150
|
-
</Box>
|
|
151
|
-
</Flex>
|
|
152
|
-
</Card>
|
|
153
|
-
)
|
|
154
|
-
})}
|
|
155
|
-
</Stack>
|
|
156
|
-
)}
|
|
157
|
-
</Box>
|
|
158
|
-
</Dialog>
|
|
159
|
-
)
|
|
160
|
-
}
|