@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Deploy proxy route for Next.js App Router — drop in at app/api/vercel-deploy/[...path]/route.ts
|
|
2
|
+
//
|
|
3
|
+
// Requires `next-sanity` for webhook signature verification:
|
|
4
|
+
// npm i next-sanity
|
|
5
|
+
//
|
|
6
|
+
// The Sanity plugin is configured with:
|
|
7
|
+
// vercelDeploy({ mode: 'proxy', proxyUrl: 'https://your-site.com/api/vercel-deploy', statusKey: '…' })
|
|
8
|
+
|
|
9
|
+
import { parseBody } from 'next-sanity/webhook'
|
|
10
|
+
import {
|
|
11
|
+
envFromProcess,
|
|
12
|
+
handleCancel,
|
|
13
|
+
handleDeployRequest,
|
|
14
|
+
handleDeployments,
|
|
15
|
+
handleEvents,
|
|
16
|
+
type DeployRequestPayload,
|
|
17
|
+
} from '../core'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Status key the Studio sends.
|
|
21
|
+
*
|
|
22
|
+
* Header only — deliberately no query-string fallback, so the key cannot end up in
|
|
23
|
+
* access logs, Referer headers or CDN cache keys.
|
|
24
|
+
*/
|
|
25
|
+
function statusKeyOf(request: Request): string | null {
|
|
26
|
+
return request.headers.get('x-deploy-status-key')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Last path segment, which selects the operation. */
|
|
30
|
+
function operation(request: Request): string {
|
|
31
|
+
const { pathname } = new URL(request.url)
|
|
32
|
+
return pathname.split('/').filter(Boolean).pop() ?? ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Origins allowed to call the status endpoints, comma-separated —
|
|
37
|
+
* e.g. `https://acme.sanity.studio,http://localhost:3333`.
|
|
38
|
+
*
|
|
39
|
+
* The Studio is served from a different origin than this route and sends a custom
|
|
40
|
+
* `x-deploy-status-key` header, which forces a CORS preflight. Without this the
|
|
41
|
+
* browser blocks every status, log and cancel call.
|
|
42
|
+
*/
|
|
43
|
+
const ALLOWED_ORIGINS = (process.env.VERCEL_DEPLOY_ALLOWED_ORIGINS ?? '')
|
|
44
|
+
.split(',')
|
|
45
|
+
.map(o => o.trim())
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
|
|
48
|
+
/** CORS headers for an allowed origin, or none when the origin is not permitted. */
|
|
49
|
+
function corsHeaders(request: Request): Record<string, string> {
|
|
50
|
+
const origin = request.headers.get('origin')
|
|
51
|
+
if (!origin || !ALLOWED_ORIGINS.includes(origin)) return {}
|
|
52
|
+
return {
|
|
53
|
+
'Access-Control-Allow-Origin': origin,
|
|
54
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
55
|
+
'Access-Control-Allow-Headers': 'content-type, x-deploy-status-key',
|
|
56
|
+
'Access-Control-Max-Age': '86400',
|
|
57
|
+
Vary: 'Origin',
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Preflight. Required because of the custom status-key header. */
|
|
62
|
+
export async function OPTIONS(request: Request): Promise<Response> {
|
|
63
|
+
return new Response(null, { status: 204, headers: corsHeaders(request) })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function json(result: { status: number; body: unknown }, request: Request): Response {
|
|
67
|
+
return Response.json(result.body, { status: result.status, headers: corsHeaders(request) })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function GET(request: Request): Promise<Response> {
|
|
71
|
+
try {
|
|
72
|
+
return await handleGet(request)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Must go through json(): an uncaught throw returns a 500 with no CORS
|
|
75
|
+
// headers, which the browser refuses to expose, so the Studio shows
|
|
76
|
+
// "Failed to fetch" rather than the message the transport wrote for this.
|
|
77
|
+
return json({ status: 500, body: { error: err instanceof Error ? err.message : 'Proxy error' } }, request)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function handleGet(request: Request): Promise<Response> {
|
|
82
|
+
const env = envFromProcess(process.env)
|
|
83
|
+
const url = new URL(request.url)
|
|
84
|
+
const key = url.searchParams.get('key')
|
|
85
|
+
const statusKey = statusKeyOf(request)
|
|
86
|
+
|
|
87
|
+
switch (operation(request)) {
|
|
88
|
+
case 'deployments': {
|
|
89
|
+
const limit = Number(url.searchParams.get('limit')) || undefined
|
|
90
|
+
return json(await handleDeployments({ key, limit, statusKey }, env), request)
|
|
91
|
+
}
|
|
92
|
+
case 'events':
|
|
93
|
+
return json(await handleEvents({ key, deploymentId: url.searchParams.get('deploymentId'), statusKey }, env), request)
|
|
94
|
+
default:
|
|
95
|
+
return json({ status: 404, body: { error: 'Unknown operation' } }, request)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function POST(request: Request): Promise<Response> {
|
|
100
|
+
try {
|
|
101
|
+
return await handlePost(request)
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return json({ status: 500, body: { error: err instanceof Error ? err.message : 'Proxy error' } }, request)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function handlePost(request: Request): Promise<Response> {
|
|
108
|
+
const env = envFromProcess(process.env)
|
|
109
|
+
|
|
110
|
+
// The deploy path is driven by a signed Sanity webhook, never by a direct call.
|
|
111
|
+
// Signature verification is what makes this endpoint safe to expose publicly.
|
|
112
|
+
if (operation(request) === 'deploy') {
|
|
113
|
+
const secret = process.env.SANITY_WEBHOOK_SECRET
|
|
114
|
+
if (!secret) return json({ status: 500, body: { error: 'Missing SANITY_WEBHOOK_SECRET' } }, request)
|
|
115
|
+
|
|
116
|
+
const { isValidSignature, body } = await parseBody<DeployRequestPayload>(request, secret)
|
|
117
|
+
if (!isValidSignature) return json({ status: 401, body: { error: 'Invalid signature' } }, request)
|
|
118
|
+
if (!body) return json({ status: 400, body: { error: 'Empty payload' } }, request)
|
|
119
|
+
|
|
120
|
+
return json(await handleDeployRequest(body, env), request)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (operation(request) === 'cancel') {
|
|
124
|
+
const payload = (await request.json()) as { key?: string; deploymentId?: string }
|
|
125
|
+
return json(await handleCancel({ ...payload, statusKey: statusKeyOf(request) }, env), request)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return json({ status: 404, body: { error: 'Unknown operation' } }, request)
|
|
129
|
+
}
|
package/src/compat/code.tsx
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
// Monospace block — Studio's Code where available, a styled <code> block otherwise
|
|
2
|
-
import type { CSSProperties, ComponentType, ReactNode } from 'react'
|
|
3
|
-
import { UI, resolveExport } from './resolve'
|
|
4
|
-
|
|
5
|
-
/** The real Code when the installed @sanity/ui still exports it, otherwise undefined. */
|
|
6
|
-
const InstalledCode = resolveExport<ComponentType<{
|
|
7
|
-
size?: number
|
|
8
|
-
style?: CSSProperties
|
|
9
|
-
children: ReactNode
|
|
10
|
-
}>>(UI, 'Code')
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Styling for the fallback Code element, approximating @sanity/ui's `size={1}`.
|
|
14
|
-
* `display: block` is included deliberately — `<code>` is inline by default while
|
|
15
|
-
* upstream's Code renders a block, so omitting it would concatenate every line.
|
|
16
|
-
*/
|
|
17
|
-
const FALLBACK_CODE_STYLE: CSSProperties = {
|
|
18
|
-
display: 'block',
|
|
19
|
-
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
20
|
-
fontSize: '0.8125rem',
|
|
21
|
-
lineHeight: 1.4,
|
|
22
|
-
margin: 0,
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Monospace block. Uses Studio's Code where available, a plain `<code>` on @sanity/ui v4+. */
|
|
26
|
-
export function Code({ style, children }: { style?: CSSProperties; children: ReactNode }): React.JSX.Element {
|
|
27
|
-
if (InstalledCode) return <InstalledCode size={1} style={style}>{children}</InstalledCode>
|
|
28
|
-
return <code style={{ ...FALLBACK_CODE_STYLE, ...style }}>{children}</code>
|
|
29
|
-
}
|
package/src/compat/index.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// Single entry point for every @sanity/ui value this plugin uses, resolved against the installed major
|
|
2
|
-
export {
|
|
3
|
-
Box, Card, Flex, Grid, Text, Heading, Label, Badge, Spinner,
|
|
4
|
-
Button, TextInput, Select, Switch, Dialog, Stack,
|
|
5
|
-
} from './primitives'
|
|
6
|
-
export type { StackProps } from './primitives'
|
|
7
|
-
export { useToast, ToastViewport } from './toast'
|
|
8
|
-
export type { ToastParams, Toaster } from './toast'
|
|
9
|
-
export { Tooltip } from './tooltip'
|
|
10
|
-
export type { TooltipProps } from './tooltip'
|
|
11
|
-
export { ActionMenu } from './menu'
|
|
12
|
-
export type { ActionMenuProps, MenuAction } from './menu'
|
|
13
|
-
export { Code } from './code'
|
|
14
|
-
export { STACK_USES_GAP } from './resolve'
|
package/src/compat/menu.tsx
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
// Overflow menu — Studio's MenuButton where available, a WAI-ARIA menu button implementation otherwise
|
|
2
|
-
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
3
|
-
import type { ComponentType, SVGProps } from 'react'
|
|
4
|
-
import { UI, resolveExport } from './resolve'
|
|
5
|
-
import { Button, Card, Flex, Stack, Text } from './primitives'
|
|
6
|
-
|
|
7
|
-
/** An icon component, matching what this plugin's icon shim produces. */
|
|
8
|
-
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
|
9
|
-
|
|
10
|
-
/** One entry in an ActionMenu. Exactly one of `onClick` or `href` drives the behaviour. */
|
|
11
|
-
export type MenuAction =
|
|
12
|
-
| { key?: string; text: string; icon: IconComponent; tone?: 'critical'; onClick: () => void; href?: never }
|
|
13
|
-
| { key?: string; text: string; icon: IconComponent; tone?: 'critical'; href: string; onClick?: never }
|
|
14
|
-
|
|
15
|
-
/** Props for the compat ActionMenu — a declarative item list rather than nested JSX. */
|
|
16
|
-
export type ActionMenuProps = {
|
|
17
|
-
/** Stable DOM id for the trigger. */
|
|
18
|
-
id: string
|
|
19
|
-
/** Accessible name for the trigger, which is icon-only. */
|
|
20
|
-
label: string
|
|
21
|
-
items: MenuAction[]
|
|
22
|
-
buttonIcon: IconComponent
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const InstalledMenuButton = resolveExport<ComponentType<Record<string, unknown>>>(UI, 'MenuButton')
|
|
26
|
-
const InstalledMenu = resolveExport<ComponentType<Record<string, unknown>>>(UI, 'Menu')
|
|
27
|
-
const InstalledMenuItem = resolveExport<ComponentType<Record<string, unknown>>>(UI, 'MenuItem')
|
|
28
|
-
|
|
29
|
-
/** Whether the installed @sanity/ui still exports the full menu trio. */
|
|
30
|
-
const INSTALLED_MENU = InstalledMenuButton && InstalledMenu && InstalledMenuItem
|
|
31
|
-
? { MenuButton: InstalledMenuButton, Menu: InstalledMenu, MenuItem: InstalledMenuItem }
|
|
32
|
-
: null
|
|
33
|
-
|
|
34
|
-
/** Stable identity for an item, used for React keys and focus tracking. Labels can repeat; keys should not. */
|
|
35
|
-
const itemKey = (item: MenuAction, index: number): string => item.key ?? `${index}-${item.text}`
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Overflow menu. Uses Studio's MenuButton where available and otherwise implements
|
|
39
|
-
* the WAI-ARIA menu button pattern locally: focus moves into the menu on open,
|
|
40
|
-
* Arrow/Home/End move between items with a roving tabindex, Escape and Tab close
|
|
41
|
-
* and return focus to the trigger.
|
|
42
|
-
*
|
|
43
|
-
* The item list is snapshotted while the menu is open, so background polling
|
|
44
|
-
* cannot insert or remove rows under the pointer.
|
|
45
|
-
*/
|
|
46
|
-
export function ActionMenu({ id, label, items, buttonIcon }: ActionMenuProps): React.JSX.Element {
|
|
47
|
-
const menuId = useId()
|
|
48
|
-
const [open, setOpen] = useState(false)
|
|
49
|
-
const [activeIndex, setActiveIndex] = useState(0)
|
|
50
|
-
const wrapRef = useRef<HTMLDivElement>(null)
|
|
51
|
-
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
52
|
-
const itemRefs = useRef<(HTMLElement | null)[]>([])
|
|
53
|
-
|
|
54
|
-
// Snapshot taken at open time — the live `items` array is rebuilt on every poll.
|
|
55
|
-
const [frozenItems, setFrozenItems] = useState<MenuAction[]>(items)
|
|
56
|
-
const shownItems = open ? frozenItems : items
|
|
57
|
-
|
|
58
|
-
/** Close the menu and hand focus back to the trigger, as the menu button pattern requires. */
|
|
59
|
-
const close = useCallback((returnFocus = true) => {
|
|
60
|
-
setOpen(false)
|
|
61
|
-
if (returnFocus) triggerRef.current?.focus()
|
|
62
|
-
}, [])
|
|
63
|
-
|
|
64
|
-
const openMenu = useCallback((index: number) => {
|
|
65
|
-
setFrozenItems(items)
|
|
66
|
-
setActiveIndex(index)
|
|
67
|
-
setOpen(true)
|
|
68
|
-
}, [items])
|
|
69
|
-
|
|
70
|
-
// Move DOM focus to follow the active item while the menu is open.
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
if (!open || INSTALLED_MENU) return
|
|
73
|
-
itemRefs.current[activeIndex]?.focus()
|
|
74
|
-
}, [open, activeIndex])
|
|
75
|
-
|
|
76
|
-
// Fallback only — dismiss on outside pointer down. Escape and Tab are handled on the menu itself
|
|
77
|
-
// so they do not swallow keys belonging to any dialog the tool has open.
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
if (INSTALLED_MENU || !open) return
|
|
80
|
-
const onPointerDown = (e: MouseEvent) => {
|
|
81
|
-
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
|
|
82
|
-
}
|
|
83
|
-
document.addEventListener('mousedown', onPointerDown)
|
|
84
|
-
return () => document.removeEventListener('mousedown', onPointerDown)
|
|
85
|
-
}, [open])
|
|
86
|
-
|
|
87
|
-
const runAction = useCallback((item: MenuAction) => {
|
|
88
|
-
close()
|
|
89
|
-
item.onClick?.()
|
|
90
|
-
}, [close])
|
|
91
|
-
|
|
92
|
-
if (INSTALLED_MENU) {
|
|
93
|
-
const { MenuButton, Menu, MenuItem } = INSTALLED_MENU
|
|
94
|
-
return (
|
|
95
|
-
<MenuButton
|
|
96
|
-
id={id}
|
|
97
|
-
button={<Button mode="ghost" icon={buttonIcon} padding={2} aria-label={label} />}
|
|
98
|
-
popover={{ placement: 'bottom-end' }}
|
|
99
|
-
menu={
|
|
100
|
-
<Menu>
|
|
101
|
-
{items.map((item, i) => (
|
|
102
|
-
<MenuItem
|
|
103
|
-
key={itemKey(item, i)}
|
|
104
|
-
text={item.text}
|
|
105
|
-
icon={item.icon}
|
|
106
|
-
tone={item.tone}
|
|
107
|
-
{...(item.href
|
|
108
|
-
? { as: 'a', href: item.href, target: '_blank', rel: 'noreferrer' }
|
|
109
|
-
: { onClick: item.onClick })}
|
|
110
|
-
/>
|
|
111
|
-
))}
|
|
112
|
-
</Menu>
|
|
113
|
-
}
|
|
114
|
-
/>
|
|
115
|
-
)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Arrow/Home/End/Escape/Tab handling for the open menu, per the WAI-ARIA menu button pattern. */
|
|
119
|
-
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
|
120
|
-
const last = shownItems.length - 1
|
|
121
|
-
if (e.key === 'Escape') { e.stopPropagation(); close(); return }
|
|
122
|
-
if (e.key === 'Tab') { close(false); return }
|
|
123
|
-
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => (i >= last ? 0 : i + 1)); return }
|
|
124
|
-
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => (i <= 0 ? last : i - 1)); return }
|
|
125
|
-
if (e.key === 'Home') { e.preventDefault(); setActiveIndex(0); return }
|
|
126
|
-
if (e.key === 'End') { e.preventDefault(); setActiveIndex(last); return }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
|
|
130
|
-
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openMenu(0) }
|
|
131
|
-
else if (e.key === 'ArrowUp') { e.preventDefault(); openMenu(items.length - 1) }
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return (
|
|
135
|
-
<div ref={wrapRef} style={{ position: 'relative' }}>
|
|
136
|
-
<Button
|
|
137
|
-
ref={triggerRef}
|
|
138
|
-
mode="ghost"
|
|
139
|
-
icon={buttonIcon}
|
|
140
|
-
padding={2}
|
|
141
|
-
id={id}
|
|
142
|
-
aria-label={label}
|
|
143
|
-
aria-haspopup="menu"
|
|
144
|
-
aria-expanded={open}
|
|
145
|
-
aria-controls={open ? menuId : undefined}
|
|
146
|
-
onClick={() => (open ? close() : openMenu(0))}
|
|
147
|
-
onKeyDown={onTriggerKeyDown}
|
|
148
|
-
/>
|
|
149
|
-
{open && (
|
|
150
|
-
<Card
|
|
151
|
-
id={menuId}
|
|
152
|
-
radius={2}
|
|
153
|
-
shadow={3}
|
|
154
|
-
padding={1}
|
|
155
|
-
role="menu"
|
|
156
|
-
aria-labelledby={id}
|
|
157
|
-
onKeyDown={onMenuKeyDown}
|
|
158
|
-
style={{ position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 1000, minWidth: 200 }}
|
|
159
|
-
>
|
|
160
|
-
{/* role="none" so the menu still directly owns its menuitem children. */}
|
|
161
|
-
<Stack space={1} role="none">
|
|
162
|
-
{shownItems.map((item, i) => {
|
|
163
|
-
const Icon = item.icon
|
|
164
|
-
const row = (
|
|
165
|
-
<Flex align="center" gap={2} paddingX={2} paddingY={2}>
|
|
166
|
-
<Icon width="1em" height="1em" aria-hidden="true" />
|
|
167
|
-
<Text size={1}>{item.text}</Text>
|
|
168
|
-
</Flex>
|
|
169
|
-
)
|
|
170
|
-
// A destructive item is wrapped in a critical-tone Card so it picks up Sanity's
|
|
171
|
-
// validated foreground/background pairing rather than a hand-picked colour.
|
|
172
|
-
const content = item.tone === 'critical'
|
|
173
|
-
? <Card tone="critical" radius={1}>{row}</Card>
|
|
174
|
-
: row
|
|
175
|
-
const shared = {
|
|
176
|
-
role: 'menuitem',
|
|
177
|
-
// Roving tabindex — the menu is one tab stop, arrows move within it.
|
|
178
|
-
tabIndex: i === activeIndex ? 0 : -1,
|
|
179
|
-
ref: (el: HTMLElement | null) => { itemRefs.current[i] = el },
|
|
180
|
-
onMouseEnter: () => setActiveIndex(i),
|
|
181
|
-
style: {
|
|
182
|
-
display: 'block',
|
|
183
|
-
width: '100%',
|
|
184
|
-
textAlign: 'left' as const,
|
|
185
|
-
cursor: 'pointer',
|
|
186
|
-
borderRadius: 3,
|
|
187
|
-
background: 'none',
|
|
188
|
-
border: 0,
|
|
189
|
-
padding: 0,
|
|
190
|
-
font: 'inherit',
|
|
191
|
-
color: 'inherit',
|
|
192
|
-
textDecoration: 'none',
|
|
193
|
-
// Card suppresses its own focus ring, so the active item draws one explicitly.
|
|
194
|
-
outline: i === activeIndex ? '2px solid var(--card-focus-ring-color, currentColor)' : 'none',
|
|
195
|
-
outlineOffset: -2,
|
|
196
|
-
},
|
|
197
|
-
}
|
|
198
|
-
return item.href ? (
|
|
199
|
-
<a
|
|
200
|
-
key={itemKey(item, i)}
|
|
201
|
-
{...shared}
|
|
202
|
-
href={item.href}
|
|
203
|
-
target="_blank"
|
|
204
|
-
rel="noreferrer"
|
|
205
|
-
onClick={() => close(false)}
|
|
206
|
-
>
|
|
207
|
-
{content}
|
|
208
|
-
</a>
|
|
209
|
-
) : (
|
|
210
|
-
<button
|
|
211
|
-
key={itemKey(item, i)}
|
|
212
|
-
{...shared}
|
|
213
|
-
type="button"
|
|
214
|
-
onClick={() => runAction(item)}
|
|
215
|
-
>
|
|
216
|
-
{content}
|
|
217
|
-
</button>
|
|
218
|
-
)
|
|
219
|
-
})}
|
|
220
|
-
</Stack>
|
|
221
|
-
</Card>
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
224
|
-
)
|
|
225
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// Layout and control primitives resolved from the installed @sanity/ui, with plain-DOM fallbacks
|
|
2
|
-
import { createElement, forwardRef } from 'react'
|
|
3
|
-
import type { ComponentType, ReactNode } from 'react'
|
|
4
|
-
import type * as SanityUi from '@sanity/ui'
|
|
5
|
-
import { UI, resolveExport, STACK_USES_GAP } from './resolve'
|
|
6
|
-
|
|
7
|
-
/** Loose props for a resolved @sanity/ui primitive — upstream types vary by major. */
|
|
8
|
-
type AnyProps = Record<string, unknown>
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Build a last-resort component that renders a plain DOM element, used when the
|
|
12
|
-
* installed @sanity/ui no longer exports a name this plugin needs. It drops the
|
|
13
|
-
* design-system props it cannot honour so React does not warn about unknown
|
|
14
|
-
* attributes, and keeps children so the tool stays usable rather than blank.
|
|
15
|
-
*
|
|
16
|
-
* @param tag DOM element to render in place of the missing component.
|
|
17
|
-
*/
|
|
18
|
-
function domFallback(tag: string): ComponentType<AnyProps> {
|
|
19
|
-
const Fallback = forwardRef<HTMLElement, AnyProps>(function SanityUiFallback(props, ref) {
|
|
20
|
-
const { children, style, id, className, onClick, href, title, ...rest } = props
|
|
21
|
-
// Forward only attributes that are meaningful on a bare element.
|
|
22
|
-
const passthrough: AnyProps = { style, id, className, onClick, href, title, ref }
|
|
23
|
-
for (const key of ['role', 'type', 'value', 'checked', 'placeholder', 'disabled', 'onChange', 'onKeyDown']) {
|
|
24
|
-
if (key in rest) passthrough[key] = rest[key]
|
|
25
|
-
}
|
|
26
|
-
for (const key of Object.keys(rest)) {
|
|
27
|
-
if (key.startsWith('aria-') || key.startsWith('data-')) passthrough[key] = rest[key]
|
|
28
|
-
}
|
|
29
|
-
return createElement(tag, passthrough, children as ReactNode)
|
|
30
|
-
})
|
|
31
|
-
Fallback.displayName = `SanityUiFallback(${tag})`
|
|
32
|
-
return Fallback as unknown as ComponentType<AnyProps>
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Resolve one @sanity/ui export, falling back to a DOM element when the installed
|
|
37
|
-
* major no longer provides it. Keeps a relocated export from turning into a
|
|
38
|
-
* module-evaluation failure that stops the whole Studio from booting.
|
|
39
|
-
*
|
|
40
|
-
* @param name Export name on the @sanity/ui barrel.
|
|
41
|
-
* @param tag DOM element to degrade to.
|
|
42
|
-
*/
|
|
43
|
-
function primitive(name: string, tag: string): ComponentType<AnyProps> {
|
|
44
|
-
return resolveExport<ComponentType<AnyProps>>(UI, name) ?? domFallback(tag)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/*
|
|
48
|
-
* The value comes through the seam so a relocated export degrades instead of
|
|
49
|
-
* failing to link; the *type* is taken from the installed @sanity/ui so call
|
|
50
|
-
* sites keep full prop checking. If a future major tombstones one of these as
|
|
51
|
-
* `never` — as v4 did to Tooltip and Menu — the assertion makes every call site
|
|
52
|
-
* a build error rather than a silent runtime blank.
|
|
53
|
-
*/
|
|
54
|
-
export const Box = primitive('Box', 'div') as typeof SanityUi.Box
|
|
55
|
-
export const Card = primitive('Card', 'div') as typeof SanityUi.Card
|
|
56
|
-
export const Flex = primitive('Flex', 'div') as typeof SanityUi.Flex
|
|
57
|
-
export const Grid = primitive('Grid', 'div') as typeof SanityUi.Grid
|
|
58
|
-
export const Text = primitive('Text', 'span') as typeof SanityUi.Text
|
|
59
|
-
export const Heading = primitive('Heading', 'h2') as typeof SanityUi.Heading
|
|
60
|
-
export const Label = primitive('Label', 'label') as typeof SanityUi.Label
|
|
61
|
-
export const Badge = primitive('Badge', 'span') as typeof SanityUi.Badge
|
|
62
|
-
export const Spinner = primitive('Spinner', 'span') as typeof SanityUi.Spinner
|
|
63
|
-
export const Button = primitive('Button', 'button') as typeof SanityUi.Button
|
|
64
|
-
export const TextInput = primitive('TextInput', 'input') as typeof SanityUi.TextInput
|
|
65
|
-
export const Select = primitive('Select', 'select') as typeof SanityUi.Select
|
|
66
|
-
export const Switch = primitive('Switch', 'input') as typeof SanityUi.Switch
|
|
67
|
-
export const Dialog = primitive('Dialog', 'div') as unknown as typeof SanityUi.Dialog
|
|
68
|
-
|
|
69
|
-
const SanityStack = primitive('Stack', 'div')
|
|
70
|
-
|
|
71
|
-
/** Props for the compat Stack. Mirrors the upstream surface this plugin uses. */
|
|
72
|
-
export type StackProps = {
|
|
73
|
-
/** Spacing step on Sanity's scale, forwarded as `gap` or `space` per installed major. */
|
|
74
|
-
space?: number
|
|
75
|
-
padding?: number
|
|
76
|
-
paddingX?: number
|
|
77
|
-
paddingY?: number
|
|
78
|
-
flex?: number
|
|
79
|
-
style?: React.CSSProperties
|
|
80
|
-
className?: string
|
|
81
|
-
role?: string
|
|
82
|
-
children?: ReactNode
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Vertical stack. Forwards `space` on @sanity/ui v2 and v3 and `gap` on v4+, so
|
|
87
|
-
* one call site spells spacing correctly on either major. See STACK_USES_GAP for
|
|
88
|
-
* how the two are told apart.
|
|
89
|
-
*/
|
|
90
|
-
export function Stack({ space, children, ...rest }: StackProps): React.JSX.Element {
|
|
91
|
-
const spacing = space === undefined ? {} : STACK_USES_GAP ? { gap: space } : { space }
|
|
92
|
-
return <SanityStack {...rest} {...spacing}>{children}</SanityStack>
|
|
93
|
-
}
|
package/src/compat/resolve.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
// Reads the installed @sanity/ui and @sanity/icons namespaces so relocated exports degrade instead of failing to link
|
|
2
|
-
import * as sanityUi from '@sanity/ui'
|
|
3
|
-
import * as sanityIcons from '@sanity/icons'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The installed namespaces, read through an index signature.
|
|
7
|
-
*
|
|
8
|
-
* Both packages have split their barrels: @sanity/icons v5 removed the named
|
|
9
|
-
* `*Icon` exports, and @sanity/ui v4 moved Tooltip, Menu, MenuButton, MenuItem,
|
|
10
|
-
* Code, Popover and useToast into subpath entry points. Those subpaths do not
|
|
11
|
-
* exist on the earlier majors this plugin supports, so neither import shape works
|
|
12
|
-
* across the range.
|
|
13
|
-
*
|
|
14
|
-
* Both packages also still *declare* the moved names in their `.d.ts` — typed
|
|
15
|
-
* `never`, with a deprecation note — so a static named import type-checks at the
|
|
16
|
-
* import site and only fails when the value is used or evaluated. Reading the
|
|
17
|
-
* namespace turns a link-time failure into a value this code can branch on.
|
|
18
|
-
*
|
|
19
|
-
* Verified through Vite/Rollup: aliasing the namespace into a binding is what
|
|
20
|
-
* forces bundlers to materialise a real namespace object rather than rewriting
|
|
21
|
-
* member access into named bindings. Do not inline these back into direct
|
|
22
|
-
* `sanityUi.x` access without re-checking the emitted bundle.
|
|
23
|
-
*/
|
|
24
|
-
export const UI = sanityUi as unknown as Record<string, unknown>
|
|
25
|
-
export const ICONS = sanityIcons as unknown as Record<string, unknown>
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Look up one export by name, returning undefined when the installed major no
|
|
29
|
-
* longer provides it.
|
|
30
|
-
*
|
|
31
|
-
* @param ns Namespace to read — {@link UI} or {@link ICONS}.
|
|
32
|
-
* @param name Exact export name, e.g. `Tooltip`.
|
|
33
|
-
*/
|
|
34
|
-
export function resolveExport<T>(ns: Record<string, unknown>, name: string): T | undefined {
|
|
35
|
-
const value = ns[name]
|
|
36
|
-
// A deprecation tombstone can be present but not callable; only accept usable values.
|
|
37
|
-
return typeof value === 'function' || (typeof value === 'object' && value !== null)
|
|
38
|
-
? (value as T)
|
|
39
|
-
: undefined
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Whether the installed @sanity/ui expects `gap` rather than `space` on Stack.
|
|
44
|
-
*
|
|
45
|
-
* v4 renamed the prop, typed the old name `never`, and ignores it at runtime —
|
|
46
|
-
* so guessing wrong collapses every vertical gap silently. It also rewrote Stack
|
|
47
|
-
* from a `forwardRef` component into a plain function generic, in the same
|
|
48
|
-
* release. Probing the shape of Stack itself keeps the signal attached to the
|
|
49
|
-
* component whose prop is being chosen, rather than to an unrelated export.
|
|
50
|
-
*
|
|
51
|
-
* Verified: forwardRef object on @sanity/ui 2.16.27 and 3.5.3, plain function on
|
|
52
|
-
* 4.0.5. Passing both prop names is not an option — `gap` leaks to the DOM as a
|
|
53
|
-
* stray attribute on v2, and `space` leaks on v4.
|
|
54
|
-
*/
|
|
55
|
-
export const STACK_USES_GAP = typeof (UI.Stack as unknown) === 'function'
|