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, '>') } function replaceHtmlCommentSlot(html: string, slotName: string, content: string): string { return html.replace(``, 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() const lostRefreshBoundaries = new Set() 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() 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() 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 = `
` 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) } }) }, } }