import type { IncomingMessage, ServerResponse } from 'node:http' // import veauryVitePlugins from 'veaury/vite/index' import type { Plugin } from 'vite' import { createHash } from 'node:crypto' import { readdirSync, readFileSync } from 'node:fs' import path from 'node:path' import process from 'node:process' import VueI18n from '@intlify/unplugin-vue-i18n/vite' import tailwindcss from '@tailwindcss/vite' import Vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import IconsResolver from 'unplugin-icons/resolver' import Icons from 'unplugin-icons/vite' import Components from 'unplugin-vue-components/vite' import VueMacros from 'unplugin-vue-macros/vite' import { defineConfig } from 'vite' import devtoolsJson from 'vite-plugin-devtools-json' import EnvironmentPlugin from 'vite-plugin-environment' import VueDevTools from 'vite-plugin-vue-devtools' import Layouts from 'vite-plugin-vue-layouts' import { VueRouterAutoImports } from 'vue-router/unplugin' import VueRouter from 'vue-router/vite' import pack from './package.json' import { isGuestRoutePath } from './src/constants/guestRoutes' import { branch, getRightKey } from './scripts/utils.mjs' import 'vitest/config' type FrontendConfigKey = 'api_domain' | 'base_domain' | 'captcha_key' function getFrontendKey(key: FrontendConfigKey): string { return getRightKey(key) } function getUrl(key: 'api_domain' | 'base_domain' = 'base_domain'): string { if (branch === 'local') return `http://${getFrontendKey(key)}` else return `https://${getFrontendKey(key)}` } interface FaviconTheme { iconPrefix: string maskColor: string themeColor: string } const productionFaviconTheme: FaviconTheme = { iconPrefix: '', maskColor: '#001827', themeColor: '#ffffff', } const faviconThemes: Record = { main: productionFaviconTheme, prod: productionFaviconTheme, production: productionFaviconTheme, development: { iconPrefix: '-development', maskColor: '#119eff', themeColor: '#119eff', }, alpha: { iconPrefix: '-development', maskColor: '#119eff', themeColor: '#119eff', }, preprod: { iconPrefix: '-preprod', maskColor: '#f59e0b', themeColor: '#f59e0b', }, local: { iconPrefix: '-local', maskColor: '#22c55e', themeColor: '#22c55e', }, } function getFaviconTheme(isLocalDevServer = false): FaviconTheme { const branchTheme = faviconThemes[branch] // Plain `bun run dev` keeps branch=main and points at live config. if (isLocalDevServer && (!branchTheme || branchTheme === productionFaviconTheme)) return faviconThemes.development return branchTheme ?? productionFaviconTheme } function normalizeDevServerPath(url: string | undefined) { const requestPath = (url ?? '').split('?')[0] if (requestPath.length > 1 && requestPath.endsWith('/')) return requestPath.slice(0, -1) return requestPath } function wellKnownPasswordManagerPlugin(): Plugin { const changePasswordPath = '/.well-known/change-password' const probePath = '/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200' function middleware(req: IncomingMessage, res: ServerResponse, next: () => void) { const requestPath = normalizeDevServerPath(req.url) if (requestPath === changePasswordPath) { res.statusCode = 302 res.setHeader('Location', '/settings/account/change-password') res.end() return } if (requestPath === probePath) { res.statusCode = 404 res.end() return } next() } return { name: 'well-known-password-manager', configureServer(server) { server.middlewares.use(middleware) }, configurePreviewServer(server) { server.middlewares.use(middleware) }, } } function envFaviconPlugin(): Plugin { return { name: 'capgo-env-favicon', transformIndexHtml(html, context) { const theme = getFaviconTheme(Boolean(context.server)) const prefix = theme.iconPrefix return html .replace('href="/favicon.svg"', `href="/favicon${prefix}.svg"`) .replace('href="/favicon.png"', `href="/favicon${prefix}.png"`) .replace('href="/pwa-192x192.png"', `href="/pwa${prefix}-192x192.png"`) .replace('href="/manifest.webmanifest"', `href="/manifest${prefix}.webmanifest"`) .replace('color="#00aba9"', `color="${theme.maskColor}"`) .replace('content="#00aba9"', `content="${theme.maskColor}"`) .replace('content="#ffffff"', `content="${theme.themeColor}"`) }, } } // Lazy route chunks normally start downloading only after the entry chunk has run and the router // matched the URL: a second round of latency on every cold load. This emits a tiny classic script // (inline scripts are blocked by the CSP) that index.html fetches in parallel with the entry; it adds // modulepreload links for the page and layout chunks of the URL being opened. const pagesDir = path.resolve(__dirname, 'src/pages') const layoutsDir = path.resolve(__dirname, 'src/layouts') // `segments` are path parts where a leading ':' marks a parameter. function toRoutePattern(segments: string[]) { const parts = segments.map(segment => segment.startsWith(':') ? '[^/]+' : segment.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)) return { source: `^/${parts.join('/')}/?$`, params: segments.filter(segment => segment.startsWith(':')).length, path: `/${segments.join('/')}`, } } function fileRoutePattern(file: string) { const segments: string[] = [] for (const segment of path.relative(pagesDir, file).replace(/\.vue$/, '').split(/[\\/.]/)) { if (segment === 'index') continue if (segment.startsWith('[...')) return null segments.push(segment.startsWith('[') ? `:${segment.slice(1, -1)}` : segment) } return toRoutePattern(segments) } /** Reads `key: value` lines from a page's `` YAML block. */ function routeBlockValue(file: string, key: string) { const source = readFileSync(file, 'utf8') const start = source.indexOf('', start) if (start === -1 || end === -1) return undefined for (const line of source.slice(start, end).split('\n')) { const trimmed = line.trim() if (trimmed.startsWith(`${key}:`)) return trimmed.slice(key.length + 1).trim().replace(/^['"]|['"]$/g, '') } return undefined } function routeLayouts(file: string, routePath: string) { if (isGuestRoutePath(routePath)) return ['naked'] const layout = routeBlockValue(file, 'layout') ?? 'default' // The app and settings layouts render inside the default dashboard shell. return layout === 'app' || layout === 'settings' ? [layout, 'default'] : [layout] } function routeChunkPreloadPlugin(): Plugin { return { name: 'capgo-route-chunk-preload', apply: 'build', generateBundle: { order: 'post', handler(_outputOptions, bundle) { const html = bundle['index.html'] if (html?.type !== 'asset') return const chunks = Object.values(bundle).filter(item => item.type === 'chunk') const byModule = new Map(chunks.filter(chunk => chunk.facadeModuleId).map(chunk => [chunk.facadeModuleId!.split('?')[0], chunk])) const entryFiles = new Set() for (const chunk of chunks.filter(chunk => chunk.isEntry)) { entryFiles.add(chunk.fileName) chunk.imports.forEach(file => entryFiles.add(file)) } const byFile = new Map(chunks.map(chunk => [chunk.fileName, chunk])) const closure = (start: string[]) => { const files = new Set() const stack = [...start] while (stack.length) { const file = stack.pop()! if (files.has(file) || entryFiles.has(file)) continue files.add(file) stack.push(...(byFile.get(file)?.imports ?? [])) } return [...files] } const files: string[] = [] const fileIndex = (file: string) => { let index = files.indexOf(file) if (index === -1) index = files.push(file) - 1 return index } const layoutClosures = new Map() const layoutFiles = (layout: string) => { if (!layoutClosures.has(layout)) { const chunk = byModule.get(path.join(layoutsDir, `${layout}.vue`)) layoutClosures.set(layout, chunk ? closure([chunk.fileName]).map(fileIndex) : []) } return layoutClosures.get(layout)! } const routes: Array<{ pattern: string, params: number, layouts: string[], files: number[] }> = [] for (const [moduleId, chunk] of byModule) { if (!moduleId.startsWith(pagesDir) || !moduleId.endsWith('.vue')) continue const override = routeBlockValue(moduleId, 'path') const pattern = override ? toRoutePattern(override.split('/').filter(Boolean)) : fileRoutePattern(moduleId) if (!pattern) continue const layouts = routeLayouts(moduleId, pattern.path) const shared = new Set(layouts.flatMap(layoutFiles)) routes.push({ pattern: pattern.source, params: pattern.params, layouts, files: closure([chunk.fileName]).map(fileIndex).filter(index => !shared.has(index)) }) } // Static paths win over parameterized ones (/app/new before /app/:app). routes.sort((a, b) => a.params - b.params) const layoutNames = [...layoutClosures.keys()] const data = { f: files, l: layoutNames.map(name => layoutClosures.get(name)!), r: routes.map(route => [route.pattern, route.layouts.map(name => layoutNames.indexOf(name)), route.files]), } // Escape characters that could end the string or the script when embedded as code. const json = JSON.stringify(data).replace(/[<>/\u2028\u2029]/g, char => String.raw`\u` + char.codePointAt(0)!.toString(16).padStart(4, '0')) const source = `(function(){var d=${json},p=location.pathname;function a(i){var l=document.createElement('link');l.rel='modulepreload';l.crossOrigin='';l.href='/'+d.f[i];document.head.appendChild(l)}for(var i=0;i\n