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 './site.json' // https://vitejs.dev/config/ export default defineConfig({ plugins: [ react(), tailwindcss(), siteConfigurationPlugin(siteConfiguration), ], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, }) type SiteConfiguration = { 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 /site.json to the generated document shell. */ function siteConfigurationPlugin(config: SiteConfiguration): 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 ?? '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: '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, 'site:lang', language) result = replaceHtmlCommentSlot(result, 'site:title', escapeHtmlText(title)) result = replaceHtmlCommentSlot(result, 'site:head-start', headStart) result = replaceHtmlCommentSlot(result, 'site:head-end', headEnd) result = replaceHtmlCommentSlot(result, 'site:body-start', bodyStart) result = replaceHtmlCommentSlot(result, 'site: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: ` .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; } .bypass-link:focus { transform: translateY(0); } `, injectTo: 'head', }, { tag: 'a', attrs: { class: 'bypass-link', href: '#root' }, children: 'Skip to content', injectTo: 'body-prepend', }, ) } return { html: result, tags } }, }, } }