@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.
@@ -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
+ }
@@ -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
- }
@@ -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'
@@ -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
- }
@@ -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'