import { defineConfig, transformWithEsbuild } from 'vite' import react from '@vitejs/plugin-react' import path from 'node:path' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import postcss, { type Container } from 'postcss' import { SUPPORTED_LANGUAGES } from './src/i18n/language' const require = createRequire(import.meta.url) const tailwindBrowserRuntime = readFileSync(require.resolve('@tailwindcss/browser'), 'utf8') // §23 version probe: every production build gets a unique version id, baked // into the bundle (__APP_VERSION__) AND emitted as dist/version.json. Open tabs // compare the two and reload themselves at a safe moment after a deploy // (src/lib/app-update.ts). CI can pin a stable id via GIT_SHA/GITHUB_SHA; // otherwise the build timestamp is used (every build = a new version). const appVersion = process.env.APP_VERSION || process.env.GIT_SHA || process.env.GITHUB_SHA || `build-${Date.now().toString(36)}` // The public release version is separate from the per-build reload identifier. // About must show a package version even when CI uses a commit SHA as its ID. const releaseVersion = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version async function startupAssets() { // Inline only the tokens the startup screen needs, retaining their original // selectors and media queries so the app and preboot cannot drift apart. const properties = new Set([ '--accent-hue', '--color-bg', '--color-fg', '--color-fg-muted', '--color-accent', '--color-accent-hover', '--color-accent-fg', '--color-ring', '--font-sans', '--z-overlay', '--z-max', ]) const tokens = postcss.parse(readFileSync(new URL('./src/styles/tokens.css', import.meta.url), 'utf8')) function prune(container: Container) { container.each((node) => { if (node.type === 'decl') { if (!properties.has(node.prop)) node.remove() } else if ((node.type === 'rule' || node.type === 'atrule') && node.nodes) { prune(node) if (!node.nodes.length) node.remove() } else node.remove() }) } prune(tokens) const wordmark = readFileSync(new URL('./src/assets/brand/aivory-wordmark.svg', import.meta.url)).toString('base64') const styles = tokens.toString() + `\n:root{--aivory-startup-wordmark:url("data:image/svg+xml;base64,${wordmark}")}\n` + readFileSync(new URL('./src/styles/startup.css', import.meta.url), 'utf8') const messages = Object.fromEntries(SUPPORTED_LANGUAGES.map(({ code }) => [code, JSON.parse(readFileSync(new URL(`./src/i18n/locales/${code}/common.json`, import.meta.url), 'utf8')).startup, ])) const languages = await transformWithEsbuild( readFileSync(new URL('./src/i18n/language.ts', import.meta.url), 'utf8'), 'language.ts', { format: 'iife', globalName: 'aivoryLanguages', minify: true }, ) const script = `(function(){var startupMessages=${JSON.stringify(messages)};${languages.code}\n` + readFileSync(new URL('./src/lib/startup-preboot.js', import.meta.url), 'utf8') + '\n})()' return { styles, script: script.replace(/<\/script/gi, '<\\/script'), messages } } // https://vitejs.dev/config/ export default defineConfig({ define: { __APP_VERSION__: JSON.stringify(appVersion), __RELEASE_VERSION__: JSON.stringify(releaseVersion), }, plugins: [ react(), { name: 'aivory-inline-startup', transformIndexHtml: { order: 'pre', async handler(html, context) { if (context.filename !== path.resolve(__dirname, 'index.html')) return html const { styles, script, messages } = await startupAssets() return html .replace('/* aivory:startup-styles */', styles) .replace('/* aivory:startup-script */', script) .replace('', `
${messages.zh.noScript}
${messages.en.noScript}
`) }, }, }, { name: 'aivory-emit-version-json', apply: 'build', generateBundle() { this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify({ version: appVersion }), }) }, }, { name: 'aivory-stable-tailwind-browser-runtime', configureServer(server) { server.middlewares.use('/tailwind-browser.js', (_req, res) => { res.statusCode = 200 res.setHeader('Content-Type', 'text/javascript; charset=utf-8') res.setHeader('Cache-Control', 'no-cache') res.end(tailwindBrowserRuntime) }) }, generateBundle() { this.emitFile({ type: 'asset', fileName: 'tailwind-browser.js', source: tailwindBrowserRuntime, }) }, }, ], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, optimizeDeps: { // The dev-time dependency pre-bundler targets es2020 by default, but // @docx-editor.dev/core ships TOP-LEVEL AWAIT in its dist. Production builds // already pass (build.target is es2022) — without this the dev server alone // fails to optimise the Word editor's chunk, so `npm run dev` breaks the // moment a .docx is opened. Keep the two targets aligned. esbuildOptions: { target: 'es2022', }, }, server: { port: 5173, host: true, proxy: { '/api': { target: process.env.VITE_API_TARGET ?? 'http://localhost:8787', changeOrigin: true, // SSE (fetch streaming) proxies fine over HTTP/1.1. ws:true forwards the // WebSocket upgrade for /api/audio/stream (live Volcano voice) to the Go // backend in dev; HMR runs on Vite's own socket, so this is scoped to // /api and doesn't touch it. ws: true, }, }, }, build: { target: 'es2022', sourcemap: false, cssCodeSplit: true, }, worker: { format: 'es', }, })