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'),
},
},
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:5173",
changeOrigin: false,
xfwd: true,
},
},
},
build: {
sourcemap: true,
},
})
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 }
},
},
}
}