Files
pleno-vue/vite.config.js
T

530 lines
20 KiB
JavaScript

import path from 'node:path'
import fs from 'node:fs'
import crypto from 'node:crypto'
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import { viteSingleFile } from 'vite-plugin-singlefile'
import { VitePWA } from 'vite-plugin-pwa'
import VueJsx from '@vitejs/plugin-vue-jsx'
import { execSync } from 'node:child_process'
const BUEFY_CSS_REPLACEMENTS = new Map([
[
'@media screen and (max-width: calc(var(--bulma-sidebar-mobile-breakpoint) - 1px))',
'@media screen and (max-width: 768px)'
],
[
'@media screen and (max-width: var(--bulma-steps-mobile-max-width)-1px)',
'@media screen and (max-width: 768px)'
],
[
'var(--bulma-$steps-divider-height)',
'var(--bulma-steps-divider-height)'
]
])
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
const fromProjectRoot = (...segments) => path.join(projectRoot, ...segments)
const PUBLIC_ASSET_ALIASES = [
['favicons/favicon-96x96.png', 'assets/favicons/favicon-96x96.png'],
['favicons/favicon.svg', 'assets/favicons/favicon.svg'],
['favicons/favicon.ico', 'assets/favicons/favicon.ico'],
['favicons/apple-touch-icon.png', 'assets/favicons/apple-touch-icon.png'],
['favicons/web-app-manifest-192x192.png', 'assets/favicons/web-app-manifest-192x192.png'],
['favicons/web-app-manifest-512x512.png', 'assets/favicons/web-app-manifest-512x512.png'],
]
function getGitCommit() {
try {
return execSync('git rev-parse --short HEAD').toString().trim()
} catch {
return 'unknown'
}
}
function getGitCommitSha() {
try {
return execSync('git rev-parse HEAD').toString().trim()
} catch {
return 'unknown'
}
}
function releaseBuildId(commitSha) {
const explicitBuildId = process.env.RELEASE_BUILD_ID || process.env.BUILD_ID
if (explicitBuildId) {
return explicitBuildId
}
if (process.env.GITHUB_RUN_ID) {
return [process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT].filter(Boolean).join('-')
}
const prefix = commitSha !== 'unknown' ? commitSha.slice(0, 12) : 'local'
return `${prefix}-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`
}
function releaseUrlPath(value, basePath = '/') {
if (!value || value.startsWith('#') || /^(?:data|mailto|tel|javascript):/i.test(value)) {
return ''
}
try {
const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}`
const url = new URL(value, `https://release.local${normalizedBasePath}`)
if (url.origin !== 'https://release.local') {
return ''
}
return url.pathname
} catch {
return ''
}
}
function releaseManifestDistPath(outputDirectory, urlPath) {
try {
const normalizedPath = decodeURIComponent(new URL(urlPath, 'https://release.local').pathname).replace(/^\/+/, '')
const resolvedPath = path.resolve(outputDirectory, normalizedPath)
const resolvedOutputDirectory = path.resolve(outputDirectory)
if (
resolvedPath !== resolvedOutputDirectory &&
!resolvedPath.startsWith(`${resolvedOutputDirectory}${path.sep}`)
) {
return ''
}
return resolvedPath
} catch {
return ''
}
}
function releaseManifestHash(filePath) {
const contents = fs.readFileSync(filePath)
return {
sha256: crypto.createHash('sha256').update(contents).digest('hex'),
bytes: contents.length
}
}
function patchBuefyCssMediaQuery() {
return {
name: 'patch-buefy-css-media-query',
enforce: 'pre',
transform(code, id) {
if (!id.includes('buefy/dist/css/buefy.css')) {
return null
}
let nextCode = code
let didPatch = false
for (const [invalidSnippet, fixedSnippet] of BUEFY_CSS_REPLACEMENTS.entries()) {
if (!nextCode.includes(invalidSnippet)) {
continue
}
nextCode = nextCode.replaceAll(invalidSnippet, fixedSnippet)
didPatch = true
}
if (!didPatch) {
return null
}
return nextCode
}
}
}
function releaseEntryManifest() {
return {
name: 'release-entry-manifest',
generateBundle(_, bundle) {
const chunks = Object.values(bundle).filter((item) => item.type === 'chunk')
const mainChunk = chunks.find((chunk) =>
String(chunk.facadeModuleId || '').replace(/\\/g, '/').endsWith('/src/main.js')
) || chunks.find((chunk) => chunk.name === 'main' && /^assets\/main-[\w-]+\.js$/.test(chunk.fileName))
if (!mainChunk) {
this.warn('Could not find src/main.js chunk for release-entry.json')
return
}
const css = Array.from(mainChunk.viteMetadata?.importedCss || [])
this.emitFile({
type: 'asset',
fileName: 'release-entry.json',
source: JSON.stringify(
{
entry: mainChunk.fileName,
css
},
null,
2
)
})
}
}
}
function publicAssetAliases() {
return {
name: 'public-asset-aliases',
generateBundle() {
for (const [source, fileName] of PUBLIC_ASSET_ALIASES) {
const sourcePath = fromProjectRoot('public', source)
if (!fs.existsSync(sourcePath)) {
this.warn(`Could not find public asset alias source: ${source}`)
continue
}
this.emitFile({
type: 'asset',
fileName,
source: fs.readFileSync(sourcePath)
})
}
}
}
}
function rootPwaManifestAlias() {
return {
name: 'root-pwa-manifest-alias',
writeBundle(options) {
const configuredOutputDirectory = options.dir || 'dist'
const outputDirectory = path.isAbsolute(configuredOutputDirectory)
? configuredOutputDirectory
: fromProjectRoot(configuredOutputDirectory)
const sourcePath = path.join(outputDirectory, 'assets', 'manifest.webmanifest')
const targetPath = path.join(outputDirectory, 'manifest.webmanifest')
if (!fs.existsSync(sourcePath)) {
this.warn('Could not find assets/manifest.webmanifest for root manifest alias')
return
}
const manifest = JSON.parse(fs.readFileSync(sourcePath, 'utf8'))
manifest.icons = (manifest.icons || []).map((icon) => {
if (
!icon.src ||
icon.src.startsWith('/') ||
icon.src.startsWith('assets/') ||
/^[a-z][a-z0-9+.-]*:/i.test(icon.src)
) {
return icon
}
return {
...icon,
src: `assets/${icon.src}`
}
})
fs.writeFileSync(targetPath, JSON.stringify(manifest))
}
}
}
function releaseMetadataManifest() {
let outputDirectory = fromProjectRoot('dist')
return {
name: 'release-metadata-manifest',
enforce: 'post',
configResolved(config) {
outputDirectory = path.isAbsolute(config.build.outDir)
? config.build.outDir
: path.resolve(config.root, config.build.outDir)
},
closeBundle() {
const releaseEntryPath = path.join(outputDirectory, 'release-entry.json')
const indexPath = path.join(outputDirectory, 'index.html')
if (!fs.existsSync(releaseEntryPath)) {
this.error('Could not find release-entry.json for release-manifest.json')
return
}
if (!fs.existsSync(indexPath)) {
this.error('Could not find index.html for release-manifest.json')
return
}
const releaseEntry = JSON.parse(fs.readFileSync(releaseEntryPath, 'utf8'))
const indexHtml = fs.readFileSync(indexPath, 'utf8')
const rootFiles = fs.readdirSync(outputDirectory)
const indexAssetUrls = Array.from(indexHtml.matchAll(/\b(?:href|src)=["']([^"']+)["']/g))
.map((match) => releaseUrlPath(match[1], '/index.html'))
.filter(Boolean)
const releaseEntryAssetUrls = [
releaseEntry.entry ? `/${releaseEntry.entry}` : '',
...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map((fileName) => `/${fileName}`) : [])
].filter(Boolean)
const pwaAssetUrls = [
'manifest.json',
'manifest.webmanifest',
'assets/manifest.webmanifest',
'favicon.ico',
'favicon_default.ico',
'pleno-favicon.ico',
'registerSW.js',
'sw.js',
...rootFiles.filter((fileName) => /^workbox-[^/]+\.js$/.test(fileName))
]
.filter((fileName) => fs.existsSync(path.join(outputDirectory, fileName)))
.map((fileName) => `/${fileName.replace(/\\/g, '/')}`)
for (const manifestPath of ['manifest.webmanifest', 'assets/manifest.webmanifest']) {
const absoluteManifestPath = path.join(outputDirectory, manifestPath)
if (!fs.existsSync(absoluteManifestPath)) {
continue
}
try {
const pwaManifest = JSON.parse(fs.readFileSync(absoluteManifestPath, 'utf8'))
for (const icon of pwaManifest.icons || []) {
const iconPath = releaseUrlPath(icon.src || '', `/${manifestPath}`)
if (iconPath) {
pwaAssetUrls.push(iconPath)
}
}
} catch {
this.warn(`Could not parse ${manifestPath} while generating release-manifest.json`)
}
}
const criticalAssetUrls = Array.from(new Set([
'/index.html',
'/release-entry.json',
...indexAssetUrls,
...releaseEntryAssetUrls,
...pwaAssetUrls
]))
const missingAssets = []
const assetHashes = {}
for (const assetUrl of criticalAssetUrls) {
const assetPath = releaseManifestDistPath(outputDirectory, assetUrl)
if (!assetPath || !fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) {
missingAssets.push(assetUrl)
continue
}
assetHashes[assetUrl] = releaseManifestHash(assetPath)
}
if (missingAssets.length > 0) {
this.error(`Critical release assets are missing from dist: ${missingAssets.join(', ')}`)
return
}
const commitSha = getGitCommitSha()
fs.writeFileSync(
path.join(outputDirectory, 'release-manifest.json'),
JSON.stringify(
{
schema_version: 1,
build_id: releaseBuildId(commitSha),
commit_sha: commitSha,
created_at: new Date().toISOString(),
entry: releaseEntry.entry,
css: Array.isArray(releaseEntry.css) ? releaseEntry.css : [],
index_asset_urls: Array.from(new Set(indexAssetUrls)),
pwa_asset_urls: Array.from(new Set(pwaAssetUrls)),
asset_urls: criticalAssetUrls,
asset_hashes: assetHashes
},
null,
2
)
)
}
}
}
export function createApiProxyOptions(env = process.env) {
const stripPrefix = env.VITE_API_PROXY_STRIP_PREFIX !== 'false'
return {
target: env.VITE_API_PROXY_TARGET || 'http://localhost',
changeOrigin: true,
secure: false,
...(stripPrefix
? {
rewrite: (requestPath) => requestPath.replace(/^\/api(?=\/|$)/, '') || '/'
}
: {})
}
}
export default defineConfig(({ mode }) => {
const isProd = mode === 'production'
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
// Set COMMIT_HASH env var for use in the app
const version = process.env.npm_package_version || '0.0.0'
const commit = getGitCommit()
console.log(`Building version ${version} (commit: ${commit})`)
process.env.COMMIT_HASH = commit
process.env.APP_VERSION = version
process.env.IS_DEV = !isProd ? 'true' : 'false'
// Server version updates happen after deploy verification, not during asset builds.
// Keep a relative base for static hosting; align manifest with base
const base = '/'
const pwaScope = base === './' ? '.' : base
// Enable single-file build only when explicitly requested (disabled by default for PWA)
const enableSingleFile = process.env.VITE_SINGLE_FILE === 'true' && !isProd
return {
base,
plugins: [
patchBuefyCssMediaQuery(),
vue(),
VueJsx(),
releaseEntryManifest(),
publicAssetAliases(),
!isProd && !isPlaywrightRuntime && vueDevTools(),
enableSingleFile && viteSingleFile(),
VitePWA({
registerType: 'autoUpdate',
injectRegister: isProd ? 'auto' : false,
manifestFilename: 'assets/manifest.webmanifest',
strategies: 'generateSW',
devOptions: {
enabled: false // keep for local dev
},
workbox: {
maximumFileSizeToCacheInBytes: 12 * 1024 * 1024,
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
globPatterns: ['**/*.{js,css,html,ico,svg,png,webmanifest,woff2}'],
globIgnores: ['**/registerSW.js', '**/sw.js'],
runtimeCaching: [
{
// cache API responses
urlPattern: ({ url }) => url.origin === 'https://api-v2.truckwash.io',
handler: 'NetworkFirst',
options: {
cacheName: 'pleno-api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 5 * 60 // 5 minutes
},
networkTimeoutSeconds: 10,
cacheableResponse: {
statuses: [200] // avoid opaque caching for API
}
}
},
{
// cache same-origin static assets
urlPattern: ({ sameOrigin, request }) =>
sameOrigin && ['style', 'script', 'font'].includes(request.destination),
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'pleno-website-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60 // 24 hours
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
// cache images (restrict to same-origin; loosen if you use a trusted CDN)
urlPattern: ({ sameOrigin, request }) =>
sameOrigin && request.destination === 'image',
handler: 'CacheFirst',
options: {
cacheName: 'pleno-image-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60 // 7 days
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
},
manifest: {
name: 'Truck Wash Kundeportal',
short_name: 'Truck Wash',
description: 'Access your Truck Wash accounts and transactions from anywhere.',
theme_color: '#063651',
background_color: '#0787bb',
// Use relative scope when base is relative
start_url: pwaScope,
scope: pwaScope,
display: 'standalone',
orientation: 'portrait',
launch_handler: { client_mode: 'navigate-existing' },
icons: [
{ src: 'favicons/web-app-manifest-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'favicons/web-app-manifest-512x512.png', sizes: '512x512', type: 'image/png' },
// Optional: add a maskable icon
// { src: 'icons/icon-512x512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }
],
id: 'pleno-pwa-test'
},
useCredentials: true
}),
rootPwaManifestAlias(),
releaseMetadataManifest()
].filter(Boolean),
resolve: {
alias: {
'@': fromProjectRoot('src'),
'vue-router': fromProjectRoot('node_modules', 'vue-router', 'dist', 'vue-router.mjs'),
'vue-i18n': fromProjectRoot('node_modules', 'vue-i18n', 'dist', 'vue-i18n.mjs')
},
preserveSymlinks: true,
dedupe: ['vue', 'vue-router', 'vue-i18n', '@vueuse/core', '@vueuse/head', '@unhead/vue']
},
define: {
'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString()),
'import.meta.env.VITE_APP_VERSION': JSON.stringify(process.env.APP_VERSION || '0.0.0'),
'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(commit),
'import.meta.env.VITE_IS_PLAYWRIGHT': JSON.stringify(isPlaywrightRuntime),
// Tip: import.meta.env.DEV/PROD are available at runtime
'import.meta.env.VITE_IS_DEV': JSON.stringify(!isProd),
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
quietDeps: true,
silenceDeprecations: ['import', 'global-builtin', 'legacy-js-api', 'if-function']
}
}
},
test: {
setupFiles: [fromProjectRoot('tests', 'unit', 'setup.js')],
isolate: true
},
server: {
proxy: {
'/api': createApiProxyOptions()
},
watch: {
ignored: ['**/output/playwright/**', '**/node_modules.codex-backup/**']
}
},
optimizeDeps: isPlaywrightRuntime
? {
noDiscovery: true
}
: {
entries: ['index.html', 'src/**/*.{vue,js,ts,jsx,tsx}']
}
}
})