Initial commit
This commit is contained in:
388
vite.config.ts
Normal file
388
vite.config.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user