389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import { defineConfig, type HtmlTagDescriptor, type Plugin } from 'vite'
|
|
import react from '@vitejs/plugin-react'
|
|
import tailwindcss from '@tailwindcss/vite'
|
|
import path from 'node:path'
|
|
|
|
import siteConfiguration from './.figma/make/site.json'
|
|
|
|
|
|
// Vite config — https://vitejs.dev/config/
|
|
export default defineConfig(({ mode }) => {
|
|
// .figma/make/deploy-preview passes `--mode development` for cached-preview builds.
|
|
const emitSourcemaps = mode === 'development'
|
|
|
|
return {
|
|
base: process.env.FIGMA_PUBLIC_URL ? `${process.env.FIGMA_PUBLIC_URL}/` : '/',
|
|
build: {
|
|
sourcemap: emitSourcemaps ? 'inline' : false,
|
|
minify: !emitSourcemaps,
|
|
},
|
|
plugins: [
|
|
react(),
|
|
tailwindcss(),
|
|
figmaSiteConfiguration(siteConfiguration),
|
|
figmaErrorOverlayReplay(),
|
|
figmaReactRefreshBoundaryFallback(),
|
|
figmaMakeKitPlugin({ storiesGlob: '/src/**/*.stories.{ts,tsx,js,jsx}' }),
|
|
],
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(__dirname, './src'),
|
|
},
|
|
},
|
|
server: {
|
|
host: process.env.FIGMA_DEV_SERVER_HOST || '0.0.0.0',
|
|
port: parseInt(process.env.PORT || '8443'),
|
|
strictPort: true,
|
|
watch: {
|
|
ignored: [
|
|
'**/.figma/**',
|
|
],
|
|
},
|
|
},
|
|
preview: {
|
|
host: process.env.FIGMA_DEV_SERVER_HOST || '0.0.0.0',
|
|
port: parseInt(process.env.PORT || '8443'),
|
|
},
|
|
}
|
|
})
|
|
|
|
type FigmaSiteConfiguration = {
|
|
title?: string
|
|
description?: string
|
|
language?: string
|
|
robots?: {
|
|
index?: boolean
|
|
}
|
|
icons?: {
|
|
icon?: string
|
|
}
|
|
openGraph?: {
|
|
image?: string
|
|
}
|
|
analytics?: {
|
|
googleAnalyticsId?: string
|
|
}
|
|
customScripts?: {
|
|
headStart?: string
|
|
headEnd?: string
|
|
bodyStart?: string
|
|
bodyEnd?: string
|
|
}
|
|
accessibility?: {
|
|
addBypassLinks?: boolean
|
|
}
|
|
}
|
|
|
|
/** Applies /.figma/make/site.json to the generated document shell. */
|
|
function figmaSiteConfiguration(config: FigmaSiteConfiguration): Plugin {
|
|
function sanitizeHtmlValue(value: string | undefined): string {
|
|
return value?.replace(/[^a-zA-Z0-9_-]/g, '') || ''
|
|
}
|
|
function escapeHtmlText(value: string): string {
|
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
function replaceHtmlCommentSlot(html: string, slotName: string, content: string): string {
|
|
return html.replace(`<!-- ${slotName} -->`, content)
|
|
}
|
|
|
|
const title = config.title ?? "Figma Make App"
|
|
const description = config.description ?? ''
|
|
const favicon = config.icons?.icon ?? ''
|
|
const socialImage = config.openGraph?.image ?? ''
|
|
const language = sanitizeHtmlValue(config.language) || 'en'
|
|
const googleAnalyticsId = sanitizeHtmlValue(config.analytics?.googleAnalyticsId)
|
|
const headStart = config.customScripts?.headStart ?? ''
|
|
const headEnd = config.customScripts?.headEnd ?? ''
|
|
const bodyStart = config.customScripts?.bodyStart ?? ''
|
|
const bodyEnd = config.customScripts?.bodyEnd ?? ''
|
|
const robotsTxt = config.robots?.index === false ? 'User-agent: *\nDisallow: /\n' : ''
|
|
|
|
return {
|
|
name: 'figma-site-configuration',
|
|
configureServer(server) {
|
|
server.middlewares.use((req, res, next) => {
|
|
if (!robotsTxt || req.url?.split('?')[0] !== '/robots.txt') return next()
|
|
|
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
res.end(robotsTxt)
|
|
})
|
|
},
|
|
generateBundle() {
|
|
if (!robotsTxt) return
|
|
|
|
this.emitFile({
|
|
type: 'asset',
|
|
fileName: 'robots.txt',
|
|
source: robotsTxt,
|
|
})
|
|
},
|
|
transformIndexHtml: {
|
|
order: 'pre',
|
|
handler(html) {
|
|
let result = html
|
|
result = replaceHtmlCommentSlot(result, 'figma:lang', language)
|
|
result = replaceHtmlCommentSlot(result, 'figma:title', escapeHtmlText(title))
|
|
result = replaceHtmlCommentSlot(result, 'figma:head-start', headStart)
|
|
result = replaceHtmlCommentSlot(result, 'figma:head-end', headEnd)
|
|
result = replaceHtmlCommentSlot(result, 'figma:body-start', bodyStart)
|
|
result = replaceHtmlCommentSlot(result, 'figma:body-end', bodyEnd)
|
|
|
|
const tags: HtmlTagDescriptor[] = []
|
|
if (description) {
|
|
tags.push({ tag: 'meta', attrs: { name: 'description', content: description }, injectTo: 'head' })
|
|
}
|
|
if (config.robots?.index === false) {
|
|
tags.push({ tag: 'meta', attrs: { name: 'robots', content: 'noindex, nofollow' }, injectTo: 'head' })
|
|
}
|
|
if (favicon) {
|
|
tags.push({ tag: 'link', attrs: { rel: 'icon', href: favicon }, injectTo: 'head' })
|
|
}
|
|
if (title) {
|
|
tags.push({ tag: 'meta', attrs: { property: 'og:title', content: title }, injectTo: 'head' })
|
|
}
|
|
if (description) {
|
|
tags.push({ tag: 'meta', attrs: { property: 'og:description', content: description }, injectTo: 'head' })
|
|
}
|
|
if (socialImage) {
|
|
tags.push(
|
|
{ tag: 'meta', attrs: { property: 'og:image', content: socialImage }, injectTo: 'head' },
|
|
{ tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' }, injectTo: 'head' },
|
|
{ tag: 'meta', attrs: { name: 'twitter:image', content: socialImage }, injectTo: 'head' },
|
|
)
|
|
}
|
|
|
|
if (googleAnalyticsId) {
|
|
tags.push(
|
|
{
|
|
tag: 'script',
|
|
attrs: {
|
|
async: true,
|
|
src: `https://www.googletagmanager.com/gtag/js?id=${googleAnalyticsId}`,
|
|
},
|
|
injectTo: 'head',
|
|
},
|
|
{
|
|
tag: 'script',
|
|
children: `
|
|
window.dataLayer = window.dataLayer || [];
|
|
function gtag(){dataLayer.push(arguments);}
|
|
gtag('js', new Date());
|
|
gtag('config', ${JSON.stringify(googleAnalyticsId)});
|
|
`,
|
|
injectTo: 'head',
|
|
},
|
|
)
|
|
}
|
|
|
|
if (config.accessibility?.addBypassLinks) {
|
|
tags.push(
|
|
{
|
|
tag: 'style',
|
|
children: `
|
|
.figma-bypass-link {
|
|
position: fixed;
|
|
top: 8px;
|
|
left: 8px;
|
|
z-index: 2147483647;
|
|
transform: translateY(-150%);
|
|
border-radius: 6px;
|
|
background: #111827;
|
|
color: #fff;
|
|
padding: 8px 12px;
|
|
font: 600 14px/1.2 system-ui, sans-serif;
|
|
text-decoration: none;
|
|
}
|
|
.figma-bypass-link:focus {
|
|
transform: translateY(0);
|
|
}
|
|
`,
|
|
injectTo: 'head',
|
|
},
|
|
{
|
|
tag: 'a',
|
|
attrs: { class: 'figma-bypass-link', href: '#root' },
|
|
children: 'Skip to content',
|
|
injectTo: 'body-prepend',
|
|
},
|
|
)
|
|
}
|
|
|
|
return {
|
|
html: result,
|
|
tags,
|
|
}
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replay the most recent build error to clients that connect after
|
|
* it was first broadcast. Vite buffers an error payload only while
|
|
* no clients are connected and clears the buffer on the first
|
|
* reconnect (see `bufferedMessage` in `createWebSocketServer`), so
|
|
* if the preview iframe reloads after Vite already delivered an
|
|
* error to a live socket, the new socket misses the payload and
|
|
* the overlay stays hidden even though the build is still broken.
|
|
* We intercept `ws.send` to remember the latest error and replay
|
|
* it on every new connection; the cache clears on a successful
|
|
* `update` or `full-reload` so a stale overlay can't survive a
|
|
* fixed build.
|
|
*/
|
|
function figmaErrorOverlayReplay(): Plugin {
|
|
return {
|
|
name: 'figma-error-overlay-replay',
|
|
apply: 'serve',
|
|
configureServer(server) {
|
|
let lastError: object | null = null
|
|
|
|
const origSend = server.ws.send.bind(server.ws) as (...args: any[]) => void
|
|
server.ws.send = ((...args: any[]) => {
|
|
const payload = args[0]
|
|
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
const type = (payload as { type?: string }).type
|
|
if (type === 'error') {
|
|
lastError = payload as object
|
|
} else if (type === 'update' || type === 'full-reload') {
|
|
lastError = null
|
|
}
|
|
}
|
|
return origSend(...args)
|
|
}) as typeof server.ws.send
|
|
|
|
server.ws.on('connection', (socket) => {
|
|
if (lastError !== null) {
|
|
socket.send(JSON.stringify(lastError))
|
|
}
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reload when a module that previously defined a React Refresh boundary stops
|
|
* defining one. This happens when an agent moves a component into a new file
|
|
* and replaces the old module with a re-export:
|
|
*
|
|
* export { default } from './app/App'
|
|
*
|
|
* Vite otherwise accepts the update using the previous module's HMR boundary,
|
|
* but the re-export-only transform no longer registers a replacement for the
|
|
* mounted component family. React reports a successful refresh while leaving
|
|
* the old tree mounted until the page is reloaded.
|
|
*/
|
|
function figmaReactRefreshBoundaryFallback(): Plugin {
|
|
type ModuleNode = import('vite').ModuleNode
|
|
const hadRefreshBoundary = new Map<string, boolean>()
|
|
const lostRefreshBoundaries = new Set<string>()
|
|
let sendFullReload: (() => void) | null = null
|
|
|
|
return {
|
|
name: 'figma-react-refresh-boundary-fallback',
|
|
apply: 'serve',
|
|
enforce: 'post',
|
|
configureServer(server) {
|
|
sendFullReload = () => server.ws.send({ type: 'full-reload', path: '*' })
|
|
},
|
|
handleHotUpdate({ modules, server, timestamp }) {
|
|
if (lostRefreshBoundaries.size === 0) return
|
|
|
|
const visited = new Set<ModuleNode>()
|
|
const pending = [...modules]
|
|
while (pending.length > 0) {
|
|
const current = pending.pop()
|
|
if (!current || visited.has(current)) continue
|
|
visited.add(current)
|
|
|
|
const moduleId = current.id?.split('?')[0]
|
|
if (moduleId && lostRefreshBoundaries.has(moduleId)) {
|
|
const invalidated = new Set<ModuleNode>()
|
|
for (const updatedModule of modules) {
|
|
server.moduleGraph.invalidateModule(updatedModule, invalidated, timestamp, true)
|
|
}
|
|
sendFullReload?.()
|
|
return []
|
|
}
|
|
|
|
pending.push(...current.importers)
|
|
}
|
|
},
|
|
transform(code, id) {
|
|
if (!/\.[jt]sx?(?:\?|$)/.test(id) || id.includes('/node_modules/')) return null
|
|
|
|
const moduleId = id.split('?')[0] ?? id
|
|
const hasRefreshBoundary = code.includes('registerExportsForReactRefresh')
|
|
const previousHadRefreshBoundary = hadRefreshBoundary.get(moduleId)
|
|
hadRefreshBoundary.set(moduleId, hasRefreshBoundary)
|
|
|
|
if (hasRefreshBoundary) lostRefreshBoundaries.delete(moduleId)
|
|
if (previousHadRefreshBoundary && !hasRefreshBoundary) {
|
|
lostRefreshBoundaries.add(moduleId)
|
|
queueMicrotask(() => sendFullReload?.())
|
|
}
|
|
|
|
return null
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serves a blank render-target page at /.figma/make/kit.html that
|
|
* the Figma preview script drives directly. The page exposes a
|
|
* registry of every file matching `storiesGlob` on
|
|
* window.__FIGMA__.stories so the design surface can dynamically
|
|
* import + mount each entry into its own grid view.
|
|
*
|
|
* Dev-only: `apply: 'serve'` gates the plugin to `vite dev`. Prod
|
|
* builds (`vite build`) skip it entirely so the route doesn't leak
|
|
* into shipped bundles.
|
|
*/
|
|
function figmaMakeKitPlugin(options: { storiesGlob: string | string[] }): Plugin {
|
|
const storiesGlob = Array.isArray(options.storiesGlob) ? options.storiesGlob : [options.storiesGlob]
|
|
const ROUTE = '/.figma/make/kit.html'
|
|
const VIRTUAL_ID = 'virtual:figma-stories'
|
|
const RESOLVED_ID = '\0' + VIRTUAL_ID
|
|
const STORIES_MODULE = `export const stories = import.meta.glob(${JSON.stringify(storiesGlob)})`
|
|
const HTML_BOOTSTRAP = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
</head>
|
|
<body>
|
|
<div id="figma-make-kit-root"></div>
|
|
<script type="module">
|
|
import { stories } from 'virtual:figma-stories'
|
|
window.__FIGMA__ = Object.assign(window.__FIGMA__ ?? {}, { stories })
|
|
window.dispatchEvent(new CustomEvent('figma.ready'))
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
|
|
return {
|
|
name: 'figma-make-kit',
|
|
apply: 'serve',
|
|
resolveId(id) {
|
|
if (id === VIRTUAL_ID) return RESOLVED_ID
|
|
return null
|
|
},
|
|
load(id) {
|
|
if (id !== RESOLVED_ID) return null
|
|
return STORIES_MODULE
|
|
},
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
const url = req.url || ''
|
|
if (url.split('?')[0] !== ROUTE) return next()
|
|
|
|
try {
|
|
res.setHeader('Content-Type', 'text/html')
|
|
res.end(await server.transformIndexHtml(url, HTML_BOOTSTRAP))
|
|
} catch (err) {
|
|
next(err as Error)
|
|
}
|
|
})
|
|
},
|
|
}
|
|
}
|