/** * doxa-maps Vite config — per-bundle IIFE build. * WHY? -> !!! EACH BUNDLE ONLY BUNDLES WHAT IT NEEDS BASED ON ITS APP PROFILE :) * Layout: * library/ ← shared library (components, composables, config, etc.; aliased as @map) * plugin-designs// ← bundle source folder * ├── index.html ← Vite html entry (staging page; loads index.js as a module in dev) * ├── index.js ← bundle entry — registers the custom element (globs ./*.vue) * └── *.vue ← profile components at the bundle root (auto-discovered via import.meta.glob('./*.vue')) * app/doxa-maps/ ← build output tree (see below) * * Build mode: each invocation builds ONE bundle (selected via BUNDLE env var) so we can * use IIFE format with inlineDynamicImports. The npm `build` script runs vite once per * plugin-designs// folder, emitting app/doxa-maps//.js. After all * bundles are built, `generate-tree.mjs` writes the manifest + self-contained staging * HTML into the tree and copies the whole doxa-maps/ folder to every destination * (default ./app/ + each line in vite.destination.md). * * Dev mode (`npm run dev`): no BUNDLE selection — Vite serves all bundles at their html * paths under the dev server. */ import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' import { autoSpaDevPlugin } from './internal/plugin-gallery/dev-plugin.mjs' import { stripShadowCssCommentsPlugin } from './internal/vite-plugin-strip-shadow-css-comments.mjs' import { backendDevPlugin, devBackendProxy } from './internal/backend-dev-plugin.mjs' import { devSyncPlugin } from './internal/dev-sync-plugin.mjs' import { fileURLToPath } from 'node:url' import { resolve, dirname, join } from 'node:path' import { readdirSync, statSync, existsSync, readFileSync, writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' import { TREE_NAME, GATED_PROFILES, SHARE_BASE } from './internal/_tree-name.mjs' const __dirname = dirname(fileURLToPath(import.meta.url)) // Ship only the ACTIVE locale: inline `en` (the fallbackLocale) and emit the other ten beside each // bundle for the runtime to fetch. Off by default — the build is byte-identical until it is set — // so this can land, be measured, and be turned on as a separate decision. const SHIP_ONE_LOCALE = process.env.DX_SHIP_ONE_LOCALE === '1' const profilesDir = resolve(__dirname, 'plugin-designs') // The multi-MFE output folder name is configured in vite.doxa-maps-build-config.json (`name`), // so you can name the output tree independently of this bundler folder's own name. It is read in // ONE place, internal/_tree-name.mjs, which every tool shares — so renaming the tree is a one-line // config change rather than a hunt through the gates. const MFE_NAME = TREE_NAME // Default output tree. generate-tree.mjs reads the same name + copies it onward. // DOXA_OUT_ROOT redirects a build's output WITHOUT touching public/: check:composite rebuilds each // composite into a temp dir and compares bytes with the committed bundle. Unset = unchanged behaviour. // ★ A GATED bundle (gatedProfiles) never builds into public/: it goes to .gated//, which git ignores. // Leaving it out of the manifest was not enough: its .js sat in public///, so any host that // serves or copies the folder (the drop-in copies / whole) shipped it anyway. const OUT_ROOT = process.env.DOXA_OUT_ROOT ? resolve(process.env.DOXA_OUT_ROOT) : GATED_PROFILES.includes(process.env.BUNDLE) ? resolve(__dirname, '.gated', MFE_NAME) : resolve(__dirname, 'public', MFE_NAME) function discoverBundles() { if (!existsSync(profilesDir)) return {} const entries = {} for (const name of readdirSync(profilesDir)) { if (name.startsWith('_') || name.startsWith('.')) continue const dir = resolve(profilesDir, name) if (!statSync(dir).isDirectory()) continue const html = resolve(dir, 'index.html') const js = resolve(dir, 'index.js') if (!existsSync(html) || !existsSync(js)) continue entries[name] = { html, js } } return entries } // Narrow library/i18n/index.js's catalogue glob to the areas one bundle uses (see plugins below). /** Locale folders on disk — directories only, so a README beside them is not read as a locale. */ const localeDirs = () => readdirSync(join(__dirname, 'library/i18n/locales'), { withFileTypes: true }) .filter((e) => e.isDirectory()).map((e) => e.name).sort() /** The exact JSON this build will emit for one locale, so its hash describes what actually ships. */ function localeTree(areas, lc) { const root = join(__dirname, 'library/i18n/locales') const tree = {} for (const a of areas) { const p = join(root, lc, `${a}.json`) if (!existsSync(p)) continue // missing area: falls back to English by design try { tree[a] = JSON.parse(readFileSync(p, 'utf8')) } catch { /* skip an unreadable area */ } } return Object.keys(tree).length ? JSON.stringify(tree) : null } function i18nAreasPlugin(bundle) { let areas = null return { name: 'doxa-i18n-areas', enforce: 'pre', async buildStart() { const { areasForBundle } = await import('./internal/i18n-areas.mjs') areas = areasForBundle(__dirname, bundle).areas this.info?.(`i18n areas for ${bundle}: ${areas.join(', ') || '(none)'}`) }, transform(code, id) { if (!areas || !id.replace(/\\/g, '/').endsWith('/library/i18n/index.js')) return null const GLOB = "import.meta.glob('./locales/*/*.json'" if (!code.includes(GLOB)) this.error('i18n catalogue glob not found in library/i18n/index.js; cannot narrow it') // ★ THE LOCALE STAR IS THE SECOND DIMENSION. With DX_SHIP_ONE_LOCALE on, only `en` is inlined // — it is the fallbackLocale, so it must be — and the other ten are emitted below as assets // this bundle fetches. Measured on doxa-simple-map: the ten unused locales are 113 KB gzip, // 40% of what a reader downloads. Without the flag the glob keeps every locale, so this is // the same build it has always been until someone turns it on. const loc = SHIP_ONE_LOCALE ? 'en' : '*' const list = areas.length ? '[' + areas.map((a) => `'./locales/${loc}/${a}.json'`).join(', ') + ']' : "['./locales/*/__none__.json']" let out = code.replace(GLOB, 'import.meta.glob(' + list) // ★ THE PIN TRAVELS WITH THE BUNDLE. Embeds are SRI-pinned and a fetched catalogue would not // be, so each emitted file's sha384 is baked in here and passed to fetch({ integrity }). A // swapped catalogue then fails closed to English instead of injecting text into the page. // Hashed from localeTree() — the same bytes generateBundle() emits, so the pin cannot describe // a file that was never shipped. if (SHIP_ONE_LOCALE) { const sri = {} for (const lc of localeDirs()) { if (lc === 'en') continue const json = localeTree(areas, lc) if (json) sri[lc] = 'sha384-' + createHash('sha384').update(json).digest('base64') } out = `const __DX_LOCALE_SRI__ = ${JSON.stringify(sri)};\n` + out } return { code: out, map: null } }, // Emit /locales/.json for every locale the IIFE no longer carries, holding exactly // the areas this bundle uses — the same areasForBundle() answer the glob above was narrowed // with, so the two dimensions cannot drift apart. Emitted from THIS plugin on purpose: the // composite-freshness, landing-consumers, fix:sri and .gated/ steps already walk the build's // own output, so assets that appear here are covered with no new pipeline step. generateBundle() { if (!SHIP_ONE_LOCALE || !areas?.length) return for (const lc of localeDirs()) { if (lc === 'en') continue // inlined: never fetched const json = localeTree(areas, lc) // fileName is already relative to this bundle's output dir — prefixing the bundle name // again produced //locales/, which the runtime would never find. if (json) this.emitFile({ type: 'asset', fileName: `locales/${lc}.json`, source: json }) } }, } } export default defineConfig(({ command, mode }) => { // .env values (VITE_MAPBOX_TOKEN) are NOT on process.env inside this config — // load them explicitly so the dev plugin gallery can hand the token to each map. const env = loadEnv(mode, __dirname, '') const all = discoverBundles() const allNames = Object.keys(all) const target = process.env.BUNDLE const isBuild = command === 'build' if (isBuild && !target) { throw new Error( `[${MFE_NAME}] Set BUNDLE= for build, or use \`npm run build\` (loops all).\n` + `Available: ${allNames.join(', ') || '(none)'}` ) } if (isBuild && !all[target]) { // A plugin-designs// folder was passed as BUNDLE but has no index.html+index.js // entry pair (e.g. a half-authored map, or a folder still being scaffolded). Fail with // a clear, actionable message instead of a `Cannot read properties of undefined` stack. throw new Error( `[${MFE_NAME}] Bundle "${target}" is not buildable: plugin-designs/${target}/ is missing ` + `index.html and/or index.js (the custom-element entry pair every bundle needs).\n` + `Buildable bundles: ${allNames.join(', ') || '(none)'}` ) } const buildEntries = isBuild ? { [target]: all[target].js } : Object.fromEntries(allNames.map(n => [n, all[n].html])) return { // COMPILE-TIME QA GATE (release governance): the QA corner widget references // the maintainer's internal platform and must NOT exist in public // artifacts. Default FALSE strips it from every bundle (dead-branch + // treeshake); an explicitly-flagged platform build sets DOXA_QA=1. // The dev server listens on THIS MACHINE ONLY. A reachable one serves the source tree, node_modules // and the token baked into /; a remote viewer reaches it through an ssh tunnel (ssh -L), never a public // port. `--host` on the command line still overrides this for a deliberate LAN session. // Local plugin backends (backend/, e.g. rudder-engine) are started by backendDevPlugin and reached // through these same-origin routes, so a plugin calls /api//… in dev exactly as on desktop. // NOT WATCHING public/ (the first laptop run with DX_SYNC_REMOTE, 2026-09-28): a landing rewrites // dozens of built leaf index.html files there, and Vite answers each changed .html with a FULL page reload // (measured: one edited leaf page -> one full-reload broadcast), so a pull fired a burst of reloads while // git was still writing and the page went white. The dev server never serves public/ through its module // graph (the script-embed route reads it on request), so nothing there needs watching. server: { host: '127.0.0.1', proxy: devBackendProxy(env), watch: { ignored: ['**/public/**'] } }, define: { // ★ NO 'unsafe-eval' (CRITIC-4, 2026-09-26). vue-i18n's default message compiler builds each message // with `new Function`, so a partner with a strict Content-Security-Policy saw every translated string // fail. The JIT compiler (vue-i18n >= 9.3) compiles messages to an AST and evaluates it without eval; // defining the flag lets the bundler drop the eval path entirely (check:no-eval asserts it). __INTLIFY_JIT_COMPILATION__: 'true', __INTLIFY_DROP_MESSAGE_COMPILER__: 'false', __DOXA_QA__: JSON.stringify(process.env.DOXA_QA === '1'), // The output tree's name (vite.doxa-maps-build-config.json `name`, the same value OUT_ROOT uses), // for the few runtime paths that must name the tree — so renaming the tree needs no source edit. __DOXA_TREE_NAME__: JSON.stringify(MFE_NAME), // The tree's PUBLIC address for the Share menu's Embed tab (the config's shareBaseUrl), or '' when unset. __DOXA_SHARE_BASE__: JSON.stringify(SHARE_BASE), }, // Build only: shadow-DOM CSS is injected from useShadowStyles(`…`) literals that // ship verbatim, so their block comments are stripped before the Vue compiler // runs (enforce: 'pre'). Dev keeps the comments. plugins: [ // ★ ONLY THE TRANSLATION AREAS THIS BUNDLE USES (CRITIC-10). library/i18n/index.js globs every // locales/*/*.json; in a BUNDLE build the glob is narrowed to the areas internal/i18n-areas.mjs finds // in this bundle's source graph. Dev (no BUNDLE) keeps every area. check:i18n-treeshake verifies it. isBuild && target ? i18nAreasPlugin(target) : null, stripShadowCssCommentsPlugin(), // ─── STRUCTURAL shadow-DOM CSS fix (customElement mode) ────────────────── // WHY: every bundle here is a `defineCustomElement(...)` that renders inside a // SHADOW ROOT. In @vitejs/plugin-vue's DEFAULT mode, each SFC