Ctrl K
ComunidadGuías…

vite.config.mts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, FaviconTheme> = {
  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 `<route>` YAML block. */
function routeBlockValue(file: string, key: string) {
  const source = readFileSync(file, 'utf8')
  const start = source.indexOf('<route')
  const end = source.indexOf('</route>', 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<string>()
        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<string>()
          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<string, number[]>()
        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<d.r.length;i++){var r=d.r[i];if(new RegExp(r[0],'i').test(p)){r[1].forEach(function(x){d.l[x].forEach(a)});r[2].forEach(a);return}}})()\n`
        const fileName = `assets/route-preload-${createHash('sha256').update(source).digest('hex').slice(0, 8)}.js`
        this.emitFile({ type: 'asset', fileName, source })
        html.source = String(html.source).replace(/<script type="module"/, `<script async src="/${fileName}"></script>\n  <script type="module"`)
      },
    },
  }
}

const locales: string[] = []
readdirSync('./messages/')
  .forEach((file) => {
    if (file.split('.')[0] !== 'README')
      locales.push(file.split('.')[0])
  })

// One build version for both import.meta.env.VITE_APP_VERSION and /version.json (an env override
// applies to both), so the update prompt never compares two different numbers for the same build.
const appVersion = process.env.VITE_APP_VERSION || pack.version

const frontendEnvironmentVariables: Record<string, string> = {
  locales: locales.join(','),
  VITE_APP_VERSION: appVersion,
  VITE_APP_URL: getUrl(),
  VITE_API_HOST: getUrl('api_domain'),
  VITE_CAPTCHA_KEY: getFrontendKey('captcha_key'),
  VITE_BRANCH: branch,
  package_dependencies: JSON.stringify(pack.dependencies),
  domain: getUrl(),
}

// The running console polls /version.json to offer "new version available" with its changelog.
function consoleVersionFilePlugin(): Plugin {
  return {
    name: 'console-version-file',
    apply: 'build',
    generateBundle() {
      this.emitFile({ type: 'asset', fileName: 'version.json', source: `${JSON.stringify({ version: appVersion })}\n` })
    },
  }
}

function frontendEnvironmentPlugin(): Plugin {
  return EnvironmentPlugin(frontendEnvironmentVariables, { defineOn: 'import.meta.env' })
}

export default defineConfig({
  define: {
    // Every component, including VueTurnstile.vue, uses the Composition API; dropping the
    // Options API removes its runtime from the Vue chunk.
    __VUE_OPTIONS_API__: 'false',
  },
  resolve: {
    alias: {
      '~/': `${path.resolve(__dirname, 'src')}/`,
      // Workspace plugin: bundle its TypeScript sources so the app never needs its dist build.
      '@capgo/capacitor-notifications': path.resolve(__dirname, 'packages/capacitor-notifications/src/index.ts'),
    },
  },
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [
            {
              // The English catalog changes far less often than the code: its own chunk stays
              // cached across deploys instead of being re-downloaded with the entry.
              name: 'messages-en',
              test: /[\\/]messages[\\/]en\.json$/,
              priority: 2,
            },
            {
              // unplugin-icons makes every icon its own module, which became 80+ chunks of a few
              // hundred bytes each. Group icons by the pages that use them and merge the small groups.
              name: 'icons',
              test: /^~icons\//,
              entriesAware: true,
              entriesAwareMergeThreshold: 4 * 1024,
              // Icons only import Vue, which is already in the entry chunk.
              includeDependenciesRecursively: false,
              priority: 1,
            },
          ],
        },
      },
      // CI serves a built preview; include browser fixtures only in that test build.
      input: process.env.CAPGO_PLAYWRIGHT_FIXTURES === 'true'
        ? [
            path.resolve(__dirname, 'index.html'),
            path.resolve(__dirname, 'playwright/fixtures/onboarding-setup.html'),
            path.resolve(__dirname, 'playwright/fixtures/email-verification.html'),
          ]
        : undefined,
    },
  },
  plugins: [
    {
      name: 'console-api-only',
      apply: 'build',
      generateBundle() {
        for (const id of this.getModuleIds()) {
          if (/node_modules[\\/].*@supabase[+\\/]/.test(id))
            this.error(`The console must use the Capgo API instead of a Supabase SDK (imported via ${id})`)
        }
      },
    },
    wellKnownPasswordManagerPlugin(),
    consoleVersionFilePlugin(),
    envFaviconPlugin(),
    routeChunkPreloadPlugin(),
    tailwindcss(),
    devtoolsJson(),
    VueMacros({
      plugins: {
        vue: Vue({
          include: [/\.vue$/, /\.md$/],
        }),

      },
    }),
    Components({
      extensions: ['vue'],
      // allow auto import and register components used in markdown
      include: [/\.vue$/, /\.vue\?vue/],
      dts: 'src/components.d.ts',
      resolvers: [
        IconsResolver(),
      ],
    }),
    frontendEnvironmentPlugin(),

    // https://github.com/vuejs/router
    VueRouter({
      dts: 'src/route-map.d.ts',
    }),

    // https://github.com/JohnCampionJr/vite-plugin-vue-layouts
    Layouts(),
    // https://github.com/antfu/unplugin-icons
    Icons({
      autoInstall: true,
    }),

    // https://github.com/antfu/unplugin-auto-import
    AutoImport({
      imports: [
        'vue',
        '@vueuse/head',
        '@vueuse/core',
        VueRouterAutoImports,
        {
          // add any other imports you were relying on
          'vue-router/auto': ['useLink'],
        },
      ],
      dts: 'src/auto-imports.d.ts',
      dirs: [
        'src/composables',
        'src/stores',
      ],
      vueTemplate: true,
    }),

    // https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
    VueI18n({
      module: 'vue-i18n',
      runtimeOnly: true,
      compositionOnly: true,
      fullInstall: true,
      include: [path.resolve(__dirname, 'locales/**')],
    }),

    // https://github.com/webfansplz/vite-plugin-vue-devtools
    VueDevTools({
      componentInspector: false,
    }),
  ],

  server: {
    fs: {
      strict: true,
    },
  },

  optimizeDeps: {
    // Pre-scan the entire app so Playwright does not trigger late dep re-optimization
    // while navigating across lazily loaded routes in the local Vite server.
    entries: [
      'index.html',
      'src/**/*.{vue,ts,js,mts}',
    ],
    include: [
      'vue',
      'vue-router',
      '@vueuse/core',
      '@vuepic/vue-datepicker',
      '@capacitor/camera',
      '@capacitor/filesystem',
      'chart.js',
      'country-code-to-flag-emoji',
      'dayjs',
      'dompurify',
      'mime',
      'tailwindcss/colors',
      'vue-chartjs',
    ],
    exclude: [
      'vue-demi',
    ],
  },
})