import { URL, fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; import wasm from 'vite-plugin-wasm'; import type { Plugin } from 'vite'; import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import vueJsx from '@vitejs/plugin-vue-jsx'; import markdown from 'unplugin-vue-markdown/vite'; import svgLoader from 'vite-svg-loader'; import { VitePWA } from 'vite-plugin-pwa'; import AutoImport from 'unplugin-auto-import/vite'; import Components from 'unplugin-vue-components/vite'; import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'; import Unocss from 'unocss/vite'; import { configDefaults } from 'vitest/config'; import Icons from 'unplugin-icons/vite'; import IconsResolver from 'unplugin-icons/resolver'; import VueI18n from '@intlify/unplugin-vue-i18n/vite'; import fs from 'node:fs'; import fg from 'fast-glob'; import Sitemap from 'vite-plugin-sitemap'; import { visualizer } from 'rollup-plugin-visualizer'; // Where the app will be served from. The build itself is path-agnostic -- `base` below is // relative, so every asset URL is resolved against the file that references it -- and this // only seeds the `` in index.html, which is what the app and the browser read // the deployment path from at runtime (see src/utils/base-url.ts). // // Setting it is only needed for a plain static deployment into a subfolder (GitHub Pages, // `dist` dropped into a directory), because there is no server to fill it in. The Docker // image leaves it at `/` and rewrites the tag per request from the BASE_URL environment // variable instead (see nginx.conf), which is why one image can serve any subpath. const baseUrl = normalizeBaseUrl(process.env.BASE_URL); const hostname = process.env.HOSTNAME; function normalizeBaseUrl(value: string | undefined): string { const trimmed = (value ?? '').replace(/^\/+|\/+$/g, ''); if (trimmed === '') { return '/'; } // The same rule the container applies to the runtime value // (docker-entrypoint.d/18-resolve-base-url.envsh): a plain path built from unreserved // characters. This one ends up spliced into an HTML attribute below, so a value carrying // a quote would rewrite the tag around it, and one carrying a space or `..` would just // quietly produce a page that loads from the wrong place. Fail the build instead. const segments = trimmed.split('/'); if (segments.some(segment => segment === '..' || !/^[\w.~-]+$/.test(segment))) { throw new Error(`BASE_URL must be a plain path such as "/it-tools/", got ${JSON.stringify(value)}`); } return `/${segments.join('/')}/`; } // index.html gets exactly one ``, and it has to come before the first relative // URL in -- Vite injects the entry script and the stylesheet links there, so this // splices it in right after the opening tag rather than relying on tag-injection order. // The emitted string is kept byte-stable on purpose: nginx.conf matches it literally to // swap in the runtime BASE_URL. function baseHref(base: string): Plugin { return { name: 'it-tools:base-href', // Build only: the dev server always serves from the root (Vite normalizes a relative // base to `/` for `serve`), and with no `` the app falls back to `/` as well. apply: 'build', transformIndexHtml: { order: 'post', handler(html) { const withBase = html.replace(/]*)?>/i, match => `${match}\n `); if (withBase === html) { throw new Error('it-tools:base-href: no to inject the into'); } return withBase; }, }, }; } // Locales are code-split: only en is bundled eagerly, the rest become lazy chunks fetched on // first use (see src/plugins/i18n.plugin.ts). VITE_AVAILABLE_LOCALES filters the locales // offered at runtime instead of trimming the build. const includeLocales = [resolve(__dirname, 'src/tools/*/locales/**'), resolve(__dirname, 'locales/**')]; // https://vitejs.dev/config/ export default defineConfig({ plugins: [ baseHref(baseUrl), VueI18n({ runtimeOnly: true, compositionOnly: true, fullInstall: true, include: includeLocales, strictMessage: false, escapeHtml: true, }), AutoImport({ imports: [ 'vue', 'vue-router', '@vueuse/core', 'vue-i18n', { 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'], }, ], vueTemplate: true, }), Icons({ compiler: 'vue3' }), vue({ include: [/\.vue$/, /\.md$/], }), vueJsx(), markdown(), svgLoader(), VitePWA({ registerType: 'autoUpdate', // src/main.ts registers the worker itself, against the base the app resolved at // runtime. Turning the injection off means the plugin no longer infers the // `autoUpdate` workbox flags either, hence skipWaiting/clientsClaim below. injectRegister: false, workbox: { skipWaiting: true, clientsClaim: true, // Precache only the app shell so the service worker doesn't download every // tool chunk and WASM binary (~160 MB) on first visit; hashed assets are // cached on demand as tools are opened. Set VITE_PWA_FULL_PRECACHE=true to // restore full offline precaching of everything. globPatterns: process.env.VITE_PWA_FULL_PRECACHE === 'true' && !process.env.VITE_VERCEL_DEPLOY ? ['**\/*.{js,wasm,css,html}'] : ['**\/*.{css,html}'], maximumFileSizeToCacheInBytes: 25 * 1024 ** 2, // Runtimes and data that only one tool needs and that are far too big to download up front, even for a full // precache: ffmpeg core (~31 MB), the ONNX runtime used by the transformers.js tools (~21 MB), tesseract // cores (~3.7 MB each) and OCR language data. They are fetched when their tool runs and kept by the runtime // caches below once used (OCR language data by tesseract.js itself, in IndexedDB). globIgnores: [ '**/node_modules/**/*', '**/ffmpeg-core-*', '**/ort-wasm-simd-threaded*', '**/tesseract-core-*', '**/*.traineddata*', ], // Relative, like every other precache entry: workbox resolves them against the // service worker's own URL, so the same sw.js works under any deployment path. navigateFallback: 'index.html', runtimeCaching: [ { urlPattern: ({ sameOrigin, request }) => sameOrigin && (request.destination === 'script' || request.destination === 'worker'), handler: 'CacheFirst', options: { cacheName: 'app-chunks', expiration: { maxEntries: 2000, maxAgeSeconds: 60 * 60 * 24 * 30 }, cacheableResponse: { statuses: [0, 200] }, }, }, { urlPattern: ({ sameOrigin, url }) => sameOrigin && url.pathname.endsWith('.wasm'), handler: 'CacheFirst', options: { cacheName: 'app-wasm', expiration: { maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 30 }, cacheableResponse: { statuses: [0, 200] }, }, }, ], }, strategies: 'generateSW', // Every URL in here is relative to the manifest itself, which the browser fetches // from the app root -- so the manifest, like the rest of the build, does not need to // know where that root is. manifest: { name: 'IT Tools', description: 'Aggregated set of useful tools for developers.', display: 'standalone', start_url: './?utm_source=pwa&utm_medium=pwa', scope: './', orientation: 'any', theme_color: '#18a058', background_color: '#f1f5f9', icons: [ { src: './favicon-16x16.png', type: 'image/png', sizes: '16x16', }, { src: './favicon-32x32.png', type: 'image/png', sizes: '32x32', }, { src: './android-chrome-192x192.png', sizes: '192x192', type: 'image/png', }, { src: './android-chrome-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable', }, ], }, }), Components({ dirs: ['src/'], extensions: ['vue', 'md'], include: [/\.vue$/, /\.vue\?vue/, /\.md$/], resolvers: [NaiveUiResolver(), IconsResolver({ prefix: 'icon' })], // dtsTsx generates invalid `const 'Name.demo':` declarations for // components whose file names contain dots dtsTsx: false, }), Unocss(), nodePolyfills(), wasm(), visualizer(), hostname ? Sitemap({ hostname, generateRobotsTxt: true, robots: [{ userAgent: '*', allow: '/' }], dynamicRoutes: (() => { const paths = ['/', '/about']; fg.sync('src/tools/*/index.ts').forEach((file) => { const content = fs.readFileSync(file, 'utf-8'); const pathMatch = content.match(/path:\s*['"`]([^'"`]+)['"`]/); if (pathMatch) { paths.push(pathMatch[1]); } const redirectMatch = content.match(/redirectFrom:\s*\[([^\]]+)\]/); if (redirectMatch?.[1]) { const redirectPaths = redirectMatch[1].match(/['"`]([^'"`]+)['"`]/g); redirectPaths?.forEach((p) => paths.push(p.replace(/['"`]/g, ''))); } }); return paths; })(), }) : undefined, ], // Relative, so a built asset is addressed from the chunk that imports it rather than // from a path fixed at build time. This is what makes the output portable across // deployment paths; `` (see baseHref above) covers the entry points in // index.html, which the browser resolves against the document instead. base: './', resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), 'node:fs/promises': fileURLToPath(new URL('./src/_empty.ts', import.meta.url)), 'node:fs': fileURLToPath(new URL('./src/_empty.ts', import.meta.url)), // Must come before the bare `fs` entry: aliases match by prefix, so `fs` alone would rewrite // `fs/promises` to `_empty.ts/promises`. cron-parser's CronFileParser reaches for it. 'fs/promises': fileURLToPath(new URL('./src/_empty.ts', import.meta.url)), fs: fileURLToPath(new URL('./src/_empty.ts', import.meta.url)), '@babel/core': fileURLToPath(new URL('./src/_empty.ts', import.meta.url)), 'isolated-vm': fileURLToPath(new URL('./src/_empty.ts', import.meta.url)), 'onnxruntime-node': fileURLToPath(new URL('./src/_empty.ts', import.meta.url)), 'unpdf/pdfjs': fileURLToPath(new URL('./src/_empty.ts', import.meta.url)), // The ONNX runtime files shipped by src/utils/transformers-env.ts; the package's `exports` don't list them. '@huggingface/transformers/dist': fileURLToPath( new URL('./node_modules/@huggingface/transformers/dist', import.meta.url), ), 'webcrypto-liner-shim': !process.env.VERCEL ? 'webcrypto-liner-shim' : fileURLToPath(new URL('./src/_empty.ts', import.meta.url)), }, }, define: { 'import.meta.env.PACKAGE_VERSION': JSON.stringify(process.env.npm_package_version), }, test: { exclude: [...configDefaults.exclude, '**/*.e2e.spec.ts'], // Several suites assert on formatted dates and offsets (date-time-converter, // days-calculator, crontab-generator). Without a fixed zone they fail on any // machine that is not on UTC. env: { TZ: 'UTC' }, // Only a handful of suites touch the DOM; they opt in with a // `// @vitest-environment jsdom` docblock. Building a jsdom for all 128 // files cost ~2 minutes of environment setup for the 7 that need it. environment: 'node', server: { deps: { inline: ['otpauth-migration', 'proto'], }, }, }, build: { target: 'esnext', // sourcemap: !process.env.VERCEL, // minify: !process.env.VERCEL, reportCompressedSize: !process.env.VERCEL, // cssMinify: false, // modulePreload: false, rolldownOptions: { external: ['regex', './out/isolated_vm', 'isolated-vm', 'onnxruntime-node', 'unpdf/pdfjs'], output: { format: 'es', // Tesseract language data is fetched as `/.traineddata.gz`, so the bundled files keep their // name and get the content hash as a directory instead (see src/tools/ocr-image/ocr-image.vue). assetFileNames: ({ names }) => names.some((name) => name.endsWith('.traineddata.gz')) ? 'assets/[hash]/[name][extname]' : 'assets/[name]-[hash][extname]', codeSplitting: { // Tool icons are loaded through per-icon dynamic imports (see src/tools/*/index.ts); // merge them into a single lazy chunk instead of ~450 tiny ones. // includeDependenciesRecursively must stay off: with it, shared helper modules // get captured into this chunk and entry chunks end up statically importing it, // which drags the whole icon set back into the startup payload. groups: [ { name: 'tool-icons', test: /node_modules[\\/](?:@vicons[\\/]|@tabler[\\/]icons-vue[\\/]dist[\\/]esm[\\/]icons[\\/])/, includeDependenciesRecursively: false, }, ], }, }, }, }, optimizeDeps: { include: [ 'isolated-vm', '@lezer/highlight', 'pdfjs-dist', 'onnxruntime-node', 'onnxruntime-web', 'unpdf', 'unpdf/pdfjs', ...(process.env.VERCEL ? ['webcrypto-liner-shim'] : []), ], // optionally specify dependency name }, server: { watch: { ignored: ['**/.pnpm-store/**'], }, headers: { // 'Cross-Origin-Resource-Policy': 'same-site', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }, });