├── i18n └── aposVite │ └── en.json ├── .eslintrc.json ├── vite.mjs ├── test ├── modules │ ├── article-page │ │ ├── ui │ │ │ ├── public │ │ │ │ ├── article.js │ │ │ │ ├── main.css │ │ │ │ └── nested │ │ │ │ │ ├── article.js │ │ │ │ │ └── main.css │ │ │ └── src │ │ │ │ ├── main.scss │ │ │ │ ├── index.js │ │ │ │ └── main.js │ │ └── index.js │ ├── article-widget │ │ ├── ui │ │ │ └── src │ │ │ │ ├── carousel.scss │ │ │ │ ├── topic.js │ │ │ │ └── carousel.js │ │ └── index.js │ ├── @apostrophecms │ │ ├── admin-bar │ │ │ └── ui │ │ │ │ ├── src │ │ │ │ └── index.js │ │ │ │ └── apos │ │ │ │ └── apps │ │ │ │ └── AposAdminBar.js │ │ └── home-page │ │ │ └── ui │ │ │ └── src │ │ │ ├── main.js │ │ │ └── topic.js │ ├── admin-bar-component │ │ ├── ui │ │ │ └── apos │ │ │ │ └── components │ │ │ │ └── TheAposAdminBar.vue │ │ └── index.js │ └── selected-article-widget │ │ ├── ui │ │ └── src │ │ │ └── tabs.js │ │ └── index.js ├── package.json └── vite.test.js ├── .eslintignore ├── lib ├── vite-postcss-config.js ├── vite-public-config.js ├── vite-serve-config.js ├── vite-apos-config.js ├── vite-base-config.js ├── vite-plugin-apostrophe-alias.mjs └── internals.js ├── .editorconfig ├── .gitignore ├── .github └── workflows │ └── test.yml ├── LICENSE.md ├── package.json ├── CHANGELOG.md ├── index.js └── README.md /i18n/aposVite/en.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { "extends": "apostrophe" } 2 | -------------------------------------------------------------------------------- /vite.mjs: -------------------------------------------------------------------------------- 1 | export * from 'vite'; 2 | export * as default from 'vite'; 3 | -------------------------------------------------------------------------------- /test/modules/article-page/ui/public/article.js: -------------------------------------------------------------------------------- 1 | console.log('public/article.js'); 2 | -------------------------------------------------------------------------------- /test/modules/article-page/ui/public/main.css: -------------------------------------------------------------------------------- 1 | .article-main { 2 | margin: 0; 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | public/apos-frontend 2 | data/temp 3 | apos-build 4 | test/modules/**/ui/* 5 | -------------------------------------------------------------------------------- /test/modules/article-page/ui/public/nested/article.js: -------------------------------------------------------------------------------- 1 | console.log('public/nested/article.js'); 2 | -------------------------------------------------------------------------------- /test/modules/article-page/ui/public/nested/main.css: -------------------------------------------------------------------------------- 1 | .article-nested-main { 2 | margin: 0; 3 | } 4 | -------------------------------------------------------------------------------- /test/modules/article-page/ui/src/main.scss: -------------------------------------------------------------------------------- 1 | .test-article-page-main { 2 | font-weight: 300; 3 | } 4 | -------------------------------------------------------------------------------- /test/modules/article-widget/ui/src/carousel.scss: -------------------------------------------------------------------------------- 1 | .test-carousel { 2 | font-weight: bold; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /test/modules/article-page/ui/src/index.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | console.log('article-page main.js'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/modules/article-page/ui/src/main.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | console.log('article-page main.js'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/modules/@apostrophecms/admin-bar/ui/src/index.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | console.log('src/index.js'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/modules/article-widget/ui/src/topic.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | console.log('article-widget main.js'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/modules/@apostrophecms/home-page/ui/src/main.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | console.log('home-page main.js'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/modules/@apostrophecms/home-page/ui/src/topic.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | console.log('home-page topic.js'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/modules/article-widget/ui/src/carousel.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | console.log('article-widget main.js'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/modules/@apostrophecms/admin-bar/ui/apos/apps/AposAdminBar.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | console.log('AposAdminBar.js'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/modules/admin-bar-component/ui/apos/components/TheAposAdminBar.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /test/modules/selected-article-widget/ui/src/tabs.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | console.log('selected-article-widget main.js'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/modules/admin-bar-component/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | options: { 3 | ignoreUnusedFolderWarning: true 4 | }, 5 | init() { } 6 | }; 7 | -------------------------------------------------------------------------------- /lib/vite-postcss-config.js: -------------------------------------------------------------------------------- 1 | module.exports = async ({ plugins }) => { 2 | return { 3 | css: { 4 | postcss: { 5 | plugins 6 | } 7 | } 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /test/modules/article-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/widget-type', 3 | options: { 4 | ignoreUnusedFolderWarning: true 5 | }, 6 | init() { } 7 | }; 8 | -------------------------------------------------------------------------------- /test/modules/article-page/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/piece-page-type', 3 | options: { 4 | ignoreUnusedFolderWarning: true 5 | }, 6 | init() { } 7 | }; 8 | -------------------------------------------------------------------------------- /test/modules/selected-article-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/widget-type', 3 | options: { 4 | ignoreUnusedFolderWarning: true 5 | }, 6 | init() { } 7 | }; 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore MacOS X metadata forks (fusefs) 2 | ._* 3 | package-lock.json 4 | *.DS_Store 5 | node_modules 6 | test/node_modules 7 | test/apos-build 8 | test/public 9 | test/data 10 | 11 | # Never commit a CSS map file, anywhere 12 | *.css.map 13 | 14 | # vim swp files 15 | .*.sw* 16 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "/**": "This package.json file is not actually installed.", 3 | " * ": "Apostrophe requires that all npm modules to be loaded by moog", 4 | " */": "exist in package.json at project level, which for a test is here", 5 | "dependencies": { 6 | "apostrophe": "git+https://github.com/apostrophecms/apostrophe.git", 7 | "@apostrophecms/vite": "git+https://github.com/apostrophecms/vite.git" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/vite-public-config.js: -------------------------------------------------------------------------------- 1 | module.exports = async ({ 2 | sourceRoot, input 3 | }) => { 4 | const apos = await import('./vite-plugin-apostrophe-alias.mjs'); 5 | 6 | /** @type {import('vite').UserConfig} */ 7 | return { 8 | plugins: [ 9 | apos.default({ 10 | id: 'src', 11 | sourceRoot 12 | }) 13 | ], 14 | build: { 15 | rollupOptions: { 16 | input 17 | } 18 | } 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /lib/vite-serve-config.js: -------------------------------------------------------------------------------- 1 | module.exports = async ({ 2 | app, httpServer, hasHMR, hmrPort 3 | }) => { 4 | 5 | // Esnure we don't share the server if custom HMR port is provided. 6 | /** @type {import('vite').UserConfig} */ 7 | const config = { 8 | base: '/__vite', 9 | server: { 10 | middlewareMode: (hmrPort && hasHMR) 11 | ? true 12 | : { 13 | server: app 14 | }, 15 | hmr: hasHMR 16 | ? { 17 | server: hmrPort ? null : httpServer, 18 | port: hmrPort 19 | } 20 | : false 21 | } 22 | }; 23 | 24 | if (!hasHMR) { 25 | config.server.watch = null; 26 | } 27 | 28 | return config; 29 | }; 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["*"] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [20, 22, 24] 17 | mongodb-version: [6.0, 7.0] 18 | 19 | steps: 20 | - name: Git checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Start MongoDB 29 | uses: supercharge/mongodb-github-action@1.11.0 30 | with: 31 | mongodb-version: ${{ matrix.mongodb-version }} 32 | 33 | - run: npm install 34 | 35 | - run: npm test 36 | env: 37 | CI: true 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Apostrophe Technologies 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/vite", 3 | "version": "1.1.1", 4 | "description": "Vite build flow for ApostropheCMS projects.", 5 | "main": "index.js", 6 | "exports": { 7 | ".": "./index.js", 8 | "./vite": { 9 | "import": "./vite.mjs" 10 | } 11 | }, 12 | "scripts": { 13 | "eslint": "eslint --ext .js .", 14 | "lint": "npm run eslint", 15 | "spec": "mocha", 16 | "test": "npm run lint && npm run spec" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/apostrophecms/vite.git" 21 | }, 22 | "homepage": "https://github.com/apostrophecms/vite#readme", 23 | "author": "Apostrophe Technologies", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "apostrophe": "github:apostrophecms/apostrophe", 27 | "eslint-config-apostrophe": "^5.0.0", 28 | "mocha": "^10.2.0" 29 | }, 30 | "dependencies": { 31 | "@vitejs/plugin-vue": "^5.1.4", 32 | "autoprefixer": "^10.4.20", 33 | "fs-extra": "^7.0.1", 34 | "postcss-load-config": "^6.0.1", 35 | "postcss-viewport-to-container-toggle": "^2.0.0", 36 | "vite": "^6.3.5" 37 | } 38 | } -------------------------------------------------------------------------------- /lib/vite-apos-config.js: -------------------------------------------------------------------------------- 1 | const { pathToFileURL } = require('node:url'); 2 | 3 | module.exports = async ({ 4 | sourceRoot, input 5 | }) => { 6 | const vue = await import('@vitejs/plugin-vue'); 7 | const apos = await import('./vite-plugin-apostrophe-alias.mjs'); 8 | 9 | /** @type {import('vite').UserConfig} */ 10 | return { 11 | css: { 12 | preprocessorOptions: { 13 | scss: { 14 | // Hardcoded for now, we need to make it configurable in the future. 15 | // 16 | // Windows Node.js has no objection to / paths here, but it does object 17 | // to mixed paths, so normalize on / 18 | additionalData: ` 19 | @use 'sass:math'; 20 | @use 'sass:color'; 21 | @use 'sass:map'; 22 | 23 | @import "${pathToFileURL(sourceRoot.replaceAll('\\', '/'))}/@apostrophecms/ui/apos/scss/mixins/import-all.scss"; 24 | ` 25 | } 26 | } 27 | }, 28 | plugins: [ 29 | apos.default({ 30 | id: 'apos', 31 | sourceRoot 32 | }), 33 | vue.default() 34 | ], 35 | build: { 36 | chunkSizeWarningLimit: 2000, 37 | rollupOptions: { 38 | input 39 | } 40 | } 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /lib/vite-base-config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ 2 | mode, base, root, cacheDir, manifestRelPath, sourceMaps 3 | }) => { 4 | /** @type {import('vite').UserConfig} */ 5 | const config = { 6 | mode, 7 | // We might need to utilize the advanced asset settings here. 8 | // https://vite.dev/guide/build.html#advanced-base-options 9 | // For now we just use the (real) asset base URL. 10 | base, 11 | root, 12 | appType: 'custom', 13 | publicDir: false, 14 | cacheDir, 15 | clearScreen: false, 16 | // Breaks symlinked modules if not enabled 17 | resolve: { 18 | preserveSymlinks: true 19 | }, 20 | css: { 21 | preprocessorOptions: { 22 | scss: { 23 | // https://vite.dev/guide/migration#sass-now-uses-modern-api-by-default 24 | // Vite v6 uses the modern API by default, keeping this 25 | // here for future reference. 26 | // api: 'modern-compiler', 27 | silenceDeprecations: [ 'import' ] 28 | } 29 | } 30 | }, 31 | plugins: [], 32 | build: { 33 | outDir: 'dist', 34 | cssCodeSplit: true, 35 | manifest: manifestRelPath, 36 | sourcemap: sourceMaps, 37 | emptyOutDir: false, 38 | assetDir: 'assets', 39 | rollupOptions: { 40 | output: { 41 | entryFileNames: '[name]-build.js' 42 | } 43 | } 44 | } 45 | }; 46 | 47 | return config; 48 | }; 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.1 (2025-11-25) 4 | 5 | ### Changes 6 | 7 | * Intercept 403 Vite middleware responses to provide helpful error messages for host validation issues. 8 | 9 | ### Fixes 10 | 11 | * Fixes for native Windows Node.js without WSL. 12 | 13 | ## 1.1.0 (2025-06-11) 14 | 15 | ### Changes 16 | 17 | * Bumbs `eslint-config-apostrophe` to `5`, fixes errors, removes unused dependencies. 18 | * Bumps to `vite@6` 19 | * Bumps `postcss-viewport-to-container-toggle` to `2`. 20 | 21 | ## 1.0.0 (2024-12-18) 22 | 23 | ### Fixes 24 | 25 | * Uses `postcss-viewport-to-container-toggle` plugin only on `public` builds to avoid breaking apos UI out of `apos-refreshable`. 26 | 27 | ## 1.0.0-beta.2 (2024-11-20) 28 | 29 | ### Adds 30 | 31 | * Adds postcss supports for the new `postcss-viewport-to-container-toggle` that allows the breakpoint preview feature to work. 32 | * Loads postcss config file from project only for public builds. 33 | * Adds `autoprefixer` plugin only for apos builds. 34 | * Adds module debug logs when in asset debug mode (`APOS_ASSET_DEBUG=1`). 35 | * Adds an option for disabling the module preload polyfill. 36 | * Adds support for `synthetic` entrypoints, that will only process the entrypoint `prologue`. 37 | * Adds support for `Modules/...` alias for the public builds (Webpack BC). 38 | * Our Vite alias plugin now throws with an useful error message when `Modules/` alias resolver fails. 39 | * Adds sass resolver for `Modules/` alias. It works for both public and private builds in exactly the same way as the JS resolver. 40 | * Adds alias `@/` for all builds, that points to the project build source root. This works for both JS and SCSS. 41 | 42 | ### Fixes 43 | 44 | * Don't crash when there is no entrypoint of type `index`. 45 | 46 | ## 1.0.0-beta.1 (2024-10-31) 47 | 48 | * Initial beta release. 49 | -------------------------------------------------------------------------------- /lib/vite-plugin-apostrophe-alias.mjs: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | import { pathToFileURL } from 'node:url'; 3 | 4 | /** 5 | * Resolve `apos` and `public` builds alias `Modules/`. The `sourceRoot` option 6 | * should be the absolute path to `apos-build/.../src` folder. 7 | * The `id` option should be either `apos` or `src` depending on the build it's 8 | * being used for (apos or public respectively). 9 | * 10 | * @param {{ id: 'src' | 'apos', sourceRoot: string }} options 11 | * @returns {import('vite').Plugin} 12 | */ 13 | export default function VitePluginApos({ sourceRoot, id } = {}) { 14 | if (!id) { 15 | throw new Error('[vite-plugin-apostrophe-alias] `id` option is required.'); 16 | } 17 | if (!sourceRoot) { 18 | throw new Error( 19 | '[vite-plugin-apostrophe-alias] `sourceRoot` option is required.' 20 | ); 21 | } 22 | const pluginOptions = { 23 | id, 24 | sourceRoot 25 | }; 26 | 27 | return { 28 | name: 'vite-plugin-apostrophe-alias', 29 | enforce: 'pre', 30 | config() { 31 | return { 32 | css: { 33 | preprocessorOptions: { 34 | scss: { 35 | importers: [ { findFileUrl } ] 36 | } 37 | } 38 | }, 39 | resolve: { 40 | alias: { 41 | '@': `${sourceRoot}/` 42 | } 43 | } 44 | }; 45 | }, 46 | 47 | async resolveId(source, importer, options) { 48 | if (!source.startsWith('Modules/')) { 49 | return null; 50 | } 51 | 52 | const { 53 | absolutePath, moduleName, chunks 54 | } = parseModuleAlias(source, pluginOptions); 55 | 56 | const resolved = await this.resolve( 57 | absolutePath, 58 | importer, 59 | options 60 | ); 61 | 62 | if (!resolved) { 63 | // For internal debugging purposes 64 | this.warn( 65 | `Resolve attempt failed: "${source}" -> "${absolutePath}"` 66 | ); 67 | // For user-facing error messages 68 | this.error( 69 | `Unable to resolve module source "${moduleName}/ui/${id}/${join(...chunks)}" ` + 70 | `from alias "${source}".\n` + 71 | 'Please be sure to use the correct alias path. ' + 72 | 'For more information, see:\n' + 73 | 'https://docs.apostrophecms.org/guide/vite.html#resolve-alias-errors' 74 | ); 75 | } 76 | 77 | return resolved; 78 | } 79 | }; 80 | 81 | // Sass FileImporter 82 | function findFileUrl(url) { 83 | if (url.startsWith('Modules/')) { 84 | const { absolutePath } = parseModuleAlias(url, pluginOptions); 85 | 86 | return pathToFileURL(absolutePath); 87 | } 88 | return null; 89 | } 90 | } 91 | 92 | function parseModuleAlias(source, pluginOptions) { 93 | const chunks = source.replace('Modules/', '').split('/'); 94 | let moduleName = chunks.shift(); 95 | if (moduleName.startsWith('@')) { 96 | moduleName += '/' + chunks.shift(); 97 | } 98 | // Windows has no objection to / versus \\, but does object 99 | // to inconsistency, so set ourselves up for success 100 | const absolutePath = join( 101 | pluginOptions.sourceRoot, 102 | moduleName, 103 | pluginOptions.id, 104 | ...chunks 105 | ).replaceAll('\\', '/'); 106 | 107 | return { 108 | moduleName, 109 | absolutePath, 110 | chunks 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const internalLib = require('./lib/internals.js'); 3 | 4 | module.exports = { 5 | before: '@apostrophecms/asset', 6 | i18n: { 7 | aposVite: {} 8 | }, 9 | async init(self) { 10 | self.buildSourceFolderName = 'src'; 11 | self.distFolderName = 'dist'; 12 | self.buildRoot = null; 13 | self.buildRootSource = null; 14 | self.distRoot = null; 15 | self.buildManifestPath = null; 16 | 17 | // Cached metadata for the current run 18 | self.currentSourceMeta = null; 19 | self.entrypointsManifest = []; 20 | 21 | // Populated after a build has been triggered 22 | self.buildOptions = {}; 23 | self.viteDevInstance = null; 24 | self.shouldCreateDevServer = false; 25 | 26 | // Populated when a watch is triggered 27 | // UI folder -> index 28 | self.currentSourceUiIndex = {}; 29 | // /absolute/path -> index 30 | self.currentSourceFsIndex = {}; 31 | // ui/relative/path/file -> [ index1, index2 ] 32 | self.currentSourceRelIndex = new Map(); 33 | 34 | // IMPORTANT: This should not be removed. 35 | // Vite depends on both process.env.NODE_ENV and the `mode` config option. 36 | // They should be in sync and ALWAYS set. We need to patch the environment 37 | // and ensure it's set here. 38 | // Read more at https://vite.dev/guide/env-and-mode.html#node-env-and-modes 39 | if (!process.env.NODE_ENV) { 40 | process.env.NODE_ENV = 'development'; 41 | } 42 | }, 43 | 44 | handlers(self) { 45 | return { 46 | '@apostrophecms/asset:afterInit': { 47 | async registerExternalBuild() { 48 | self.apos.asset.configureBuildModule(self, { 49 | alias: 'vite', 50 | devServer: true, 51 | hmr: true 52 | }); 53 | await self.initWhenReady(); 54 | } 55 | }, 56 | '@apostrophecms/express:afterListen': { 57 | async prepareViteDevServer() { 58 | if (self.shouldCreateDevServer) { 59 | await self.createViteInstance(self.buildOptions); 60 | } 61 | } 62 | }, 63 | 'apostrophe:destroy': { 64 | async destroyBuildWatcher() { 65 | if (self.viteDevInstance) { 66 | await self.viteDevInstance.close(); 67 | self.viteDevInstance = null; 68 | } 69 | } 70 | } 71 | }; 72 | }, 73 | 74 | middleware(self) { 75 | if (process.env.NODE_ENV === 'production') { 76 | return {}; 77 | } 78 | return { 79 | viteDevServer: { 80 | before: '@apostrophecms/express', 81 | url: '/__vite', 82 | middleware: async (req, res, next) => { 83 | if (!self.shouldCreateDevServer || !self.viteDevInstance) { 84 | return res.status(403).send('forbidden'); 85 | } 86 | 87 | // Intercept 403 responses to provide helpful error messages 88 | // for host validation issues 89 | const originalWriteHead = res.writeHead; 90 | const hostname = req.headers.host; 91 | 92 | res.writeHead = function(code, ...args) { 93 | if ( 94 | code === 403 && 95 | hostname && 96 | !self.isHostnameAllowed( 97 | hostname, 98 | self.viteDevInstance?.config?.server?.allowedHosts 99 | ) 100 | ) { 101 | const hostnameWithoutPort = hostname.includes('[') 102 | ? hostname.split(']')[0] + ']' 103 | : hostname.split(':')[0]; 104 | self.apos.util.warnDevOnce( 105 | 'vite-dev-server-host-validation', 106 | 'Vite dev server blocked a request from hostname: ' + hostname + '\n' + 107 | ' This hostname is not in the allowed hosts list.\n' + 108 | ' To fix this, add the hostname to your Vite configuration:\n\n' + 109 | ' // apos.vite.config.js\n' + 110 | ' import { defineConfig } from \'@apostrophecms/vite/vite\';\n\n' + 111 | ' export default defineConfig({\n' + 112 | ' server: {\n' + 113 | ' allowedHosts: [\'' + hostnameWithoutPort + '\', \'localhost\']\n' + 114 | ' }\n' + 115 | ' });\n' 116 | ); 117 | } 118 | return originalWriteHead.apply(this, [ code, ...args ]); 119 | }; 120 | 121 | // Do not provide `next` to the middleware, we want to stop the chain here 122 | // if the request is handled by Vite. It provides its own 404 handler. 123 | self.viteDevInstance.middlewares(req, res); 124 | } 125 | } 126 | }; 127 | }, 128 | 129 | methods(self) { 130 | return { 131 | // see @apostrophecms/assset:getBuildOptions() for the options shape. 132 | // A required interface for the asset module. 133 | async build(options = {}) { 134 | self.printDebug('build-options', { buildOptions: options }); 135 | self.buildOptions = options; 136 | await self.buildBefore(options); 137 | 138 | await self.buildPublic(options); 139 | const ts = await self.buildApos(options); 140 | 141 | const viteManifest = await self.getViteBuildManifest(); 142 | self.entrypointsManifest = await self.applyManifest( 143 | self.entrypointsManifest, viteManifest 144 | ); 145 | return { 146 | entrypoints: self.entrypointsManifest, 147 | sourceMapsRoot: self.distRoot, 148 | devServerUrl: null, 149 | ts 150 | }; 151 | }, 152 | // A required interface for the asset module. 153 | async startDevServer(options) { 154 | self.printDebug('dev-server-build-options', { buildOptions: options }); 155 | self.buildOptions = options; 156 | self.shouldCreateDevServer = true; 157 | await self.buildBefore(options); 158 | 159 | const { 160 | scenes: currentScenes, 161 | build: currentBuild 162 | } = self.getCurrentMode(options.devServer); 163 | 164 | self.ensureViteClientEntry(self.entrypointsManifest, currentScenes, options); 165 | 166 | let ts; 167 | if (currentBuild === 'public') { 168 | await self.buildPublic(options); 169 | } 170 | if (currentBuild === 'apos') { 171 | ts = await self.buildApos(options); 172 | } 173 | 174 | const viteManifest = await self.getViteBuildManifest(currentBuild); 175 | self.entrypointsManifest = await self.applyManifest( 176 | self.entrypointsManifest, viteManifest 177 | ); 178 | 179 | return { 180 | entrypoints: self.entrypointsManifest, 181 | hmrTypes: [ ...new Set( 182 | self.getBuildEntrypointsFor(options.devServer) 183 | .map((entry) => entry.type) 184 | ) ], 185 | ts, 186 | devServerUrl: self.getDevServerUrl() 187 | }; 188 | }, 189 | // A required interface for the asset module. 190 | // Initialize the watcher for triggering vite HMR via file 191 | // copy to the build source. This method is called always 192 | // after the `startDevServer` method. 193 | // `chokidar` is a chockidar `FSWatcher` or compatible instance. 194 | async watch(chokidar, buildOptions) { 195 | self.printDebug('watch-build-options', { buildOptions }); 196 | self.buildWatchIndex(); 197 | // Initialize our voting system to detect what entrypoints 198 | // are concerned with a given source file change. 199 | self.setWatchVoters( 200 | self.getBuildEntrypointsFor(buildOptions.devServer) 201 | ); 202 | 203 | chokidar 204 | .on('add', (p) => self.onSourceAdd(p, false)) 205 | .on('addDir', (p) => self.onSourceAdd(p, true)) 206 | .on('change', (p) => self.onSourceChange(p)) 207 | .on('unlink', (p) => self.onSourceUnlink(p, false)) 208 | .on('unlinkDir', (p) => self.onSourceUnlink(p, true)); 209 | }, 210 | // A required interface for the asset module. 211 | // This method is called when build and watch are not triggered. 212 | // Enhance and return any entrypoints that are included in the manifest 213 | // when an actual build/devServer is triggered. 214 | // The options are same as the ones provided in the `build` and 215 | // `startDevServer` methods. 216 | async entrypoints(options) { 217 | self.printDebug('entrypoints-build-options', { buildOptions: options }); 218 | const entrypoints = self.apos.asset.getBuildEntrypoints(options.types) 219 | .filter(entrypoint => entrypoint.condition !== 'nomodule'); 220 | 221 | self.ensureInitEntry(entrypoints); 222 | 223 | if (options.devServer) { 224 | const { scenes } = self.getCurrentMode(options.devServer); 225 | self.ensureViteClientEntry(entrypoints, scenes, options); 226 | } 227 | 228 | return entrypoints; 229 | }, 230 | // A required interface for the asset module. 231 | async clearCache() { 232 | await fs.remove(self.cacheDirBase); 233 | }, 234 | // A required interface for the asset module. 235 | async reset() { 236 | await fs.remove(self.buildRoot); 237 | await fs.mkdir(self.buildRoot, { recursive: true }); 238 | }, 239 | // Check if a hostname is allowed based on Vite's allowedHosts configuration 240 | // Implements the same logic as Vite's host validation 241 | isHostnameAllowed(hostname, allowedHosts) { 242 | if (!hostname) { 243 | return true; 244 | } 245 | 246 | if (allowedHosts === true) { 247 | return true; 248 | } 249 | 250 | const hostWithoutPort = parseHostname(hostname); 251 | if (!hostWithoutPort) { 252 | return false; 253 | } 254 | 255 | // If allowedHosts is not set, Vite allows localhost and 127.0.0.1 256 | if (!allowedHosts) { 257 | return [ 'localhost', '127.0.0.1', '::1' ].includes(hostWithoutPort); 258 | } 259 | 260 | // Check if hostname matches any allowed host pattern. 261 | // Normalize by removing square brackets for IPv6 addresses, 262 | // the same as done in the parseHostname. 263 | return allowedHosts.some(allowedHost => { 264 | allowedHost = allowedHost.replace(/^\[|\]$/g, ''); 265 | // Exact match 266 | if (allowedHost === hostWithoutPort) { 267 | return true; 268 | } 269 | // Wildcard pattern (e.g., '.example.com') 270 | if (allowedHost.startsWith('.')) { 271 | return hostWithoutPort.endsWith(allowedHost) || 272 | hostWithoutPort === allowedHost.slice(1); 273 | } 274 | return false; 275 | }); 276 | 277 | function parseHostname(hostname) { 278 | try { 279 | const { hostname: parsedHostname } = new URL( 280 | `https://${hostname}` 281 | ); 282 | return parsedHostname.replace(/^\[|\]$/g, ''); 283 | } catch (e) { 284 | self.logWarn( 285 | 'parse-hostname-failed', 286 | `Failed to parse hostname: ${hostname}`, 287 | { 288 | hostname, 289 | error: e.message, 290 | stack: e.stack.split('\n').slice(1).map(line => line.trim()) 291 | } 292 | ); 293 | return null; 294 | } 295 | } 296 | }, 297 | // Internal implementation. 298 | ...internalLib(self) 299 | }; 300 | } 301 | }; 302 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | ApostropheCMS logo 4 | 5 |

Apostrophe Vite Bundling And HMR

6 |

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

17 |
18 | 19 | This extension provides Vite integration for ApostropheCMS projects, enabling module bundling and hot module replacement (HMR) during development. 20 | 21 | ## Installation 22 | 23 | To install the module, use the command line to run this command in an Apostrophe project's root directory: 24 | 25 | ``` 26 | npm install @apostrophecms/vite 27 | ``` 28 | 29 | ## Usage 30 | 31 | Add the module in the `app.js` file: 32 | 33 | ```javascript 34 | require('apostrophe')({ 35 | shortName: 'my-project', 36 | modules: { 37 | '@apostrophecms/vite': {}, 38 | } 39 | }); 40 | ``` 41 | 42 | ## Configuration 43 | 44 | ## Hot Module Replacement Configuration 45 | 46 | By default, HMR is enabled for your project's public UI code. All configuration is handled through ApostropheCMS's core asset module options, simplifying setup and maintenance. 47 | 48 | ### Enable Admin UI HMR 49 | 50 | For development work on the ApostropheCMS admin interface, you can switch HMR to target the admin UI instead of public-facing components: 51 | 52 | ```javascript 53 | require('apostrophe')({ 54 | shortName: 'my-project', 55 | modules: { 56 | '@apostrophecms/vite': {}, 57 | '@apostrophecms/asset': { 58 | options: { 59 | hmr: 'apos', // 'public' targets the project UI (default) 60 | }, 61 | }, 62 | } 63 | }); 64 | ``` 65 | 66 | ### Disable HMR 67 | 68 | You can disable hot module replacement when it is not needed or desired, while still using Vite for builds: 69 | 70 | ```javascript 71 | require('apostrophe')({ 72 | shortName: 'my-project', 73 | modules: { 74 | '@apostrophecms/vite': {}, 75 | '@apostrophecms/asset': { 76 | options: { 77 | hmr: false, 78 | }, 79 | }, 80 | } 81 | }); 82 | ``` 83 | 84 | ## Change the underlying Websocket server port 85 | During development, the hot module reload (HMR) server uses WebSocket and runs on the same port as your ApostropheCMS instance. For advanced configurations, you can run the development server as a standalone HTTP server on a different port by setting the `hmrPort` option. This can be useful when you need to avoid port conflicts or work with specific network configurations: 86 | 87 | ```javascript 88 | require('apostrophe')({ 89 | shortName: 'my-project', 90 | modules: { 91 | '@apostrophecms/vite': {}, 92 | '@apostrophecms/asset': { 93 | options: { 94 | hmrPort: 3001, 95 | }, 96 | }, 97 | } 98 | }); 99 | ``` 100 | 101 | ## Enable Source Maps in Production 102 | 103 | You can enable source maps in production to help debug minified code and view original source files in the browser DevTools. While this slightly increases the initial download size, it's valuable for debugging production issues. 104 | 105 | ```javascript 106 | require('apostrophe')({ 107 | shortName: 'my-project', 108 | modules: { 109 | '@apostrophecms/vite': {}, 110 | '@apostrophecms/asset': { 111 | options: { 112 | productionSourceMaps: true, 113 | }, 114 | }, 115 | } 116 | }); 117 | ``` 118 | 119 | ## Inject code only when HMR is enabled 120 | 121 | If you want to inject some code in your site only when in development mode and HMR is enabled, you can use the Apostrophe nunjucks components. 122 | 123 | ```njk 124 | {# module-name/views/myComponent.html #} 125 | 126 | ``` 127 | 128 | ```js 129 | // module-name/index.js 130 | module.exports = { 131 | components(self) { 132 | return { 133 | myComponent(req, data) { 134 | return {}; 135 | } 136 | }; 137 | }, 138 | init(self) { 139 | self.apos.template.prepend({ 140 | where: 'head', 141 | when: 'hmr', 142 | bundler: 'vite', 143 | component: 'module-name:myComponent' 144 | }); 145 | } 146 | }; 147 | ``` 148 | The when option controls when your component appears: 149 | 150 | ```javascript 151 | when: 'hmr' // Only visible when HMR is active 152 | when: 'dev' // Visible in any development mode 153 | when: 'prod' // Only visible in production 154 | ``` 155 | 156 | The bundler option allows you to specify which bundler must be active for the component to appear: 157 | 158 | ```javascript 159 | bundler: 'vite' // Only visible when using Vite 160 | bundler: 'webpack' // Only visible when using webpack 161 | ``` 162 | 163 | You can combine these options to precisely control when your component appears. For example, to show a component only when using Vite with HMR active, you would use both `when: 'hmr'` and `bundler: 'vite'`. 164 | 165 | ## Provided Vite Configuration 166 | 167 | While the `apos` build (the code living in the`ui/apos/` directory of every module) is fully preconfigured and doesn't allow for customization, the `public` build (the code imported within `ui/src/` ) is fully customizable and contains a minimal configuration to get you started: 168 | - A PostCSS plugin to handle core features such as "Breakpoint Preview" (when enabled) 169 | - `Modules/` alias to simplify module within the same build 170 | - `@/` alias to allow easy access to cross-module and cross-build source code 171 | 172 | ### Pre-configured Aliases 173 | 174 | The `Modules/` alias is available for both public and admin UI builds and allows you to import modules in your project without worrying about the relative path, but restricts you to only sources inside of `ui/src/` directories. 175 | 176 | ```javascript 177 | // Current file: modules/another-module/ui/src/index.js 178 | // Actual import path: modules/some-module/ui/src/lib/utils.js 179 | import utils from 'Modules/some-module/lib/utils.js'; 180 | ``` 181 | 182 | `@/` alias is available for both public and admin UI builds and allows you to import files from the entire project source code. It follows the same path as your orignal source code, but skips the `ui/` part of the path. 183 | 184 | ```javascript 185 | // Current file: any file in any module inside of the `ui/` folder 186 | // Actual path: modules/some-module/ui/src/lib/utils.js 187 | import utils from '@/some-module/src/lib/utils.js'; 188 | 189 | // Actual path: modules/some-module/ui/apos/mixins/SomeMixin.js 190 | import SomeMixin from '@/some-module/apos/mixins/SomeMixin.js'; 191 | ``` 192 | 193 | > Warning: You gain access to `public` builds from within the `apos` build, and vice versa, when using the `@/` alias. You should use it with caution, because it might lead to situations where imports are not resolved correctly. This would happen if the imported file (or its deep imports) contains `Modules/` aliased imports. On the other hand, `@/` is more developer friendly, allows auto-completion, and is more intuitive and readable. Be sure to include mostly sources from your current build and ensure no imported sources contain `Modules/` aliased imports when cross-importing from another build. 194 | 195 | ### Importing Static Assets and Sass 196 | 197 | The way we integrate Vite with ApostropheCMS allows now direct imports (including dynamic imports) of assets like images, fonts, and other files. You can import them directly in your vanilla JS/JS framework code: 198 | 199 | ```javascript 200 | // You can use aliases to import assets or a relative path when in the same module. 201 | // Actual path: modules/some-module/ui/assets/logo.svg 202 | import logo from '@/some-module/assets/logo.svg'; 203 | // Logo now cotains the path to the image and will be normallized and correctly 204 | // injected when building the project for production. 205 | ``` 206 | You can import Sass as well: 207 | 208 | ```scss 209 | /* You can use aliases to import assets */ 210 | /* Actual path: modules/some-module/ui/scss/_styles.scss */ 211 | @use '@/some-module/scss/styles'; 212 | ``` 213 | 214 | Vue JS supports importing assets directly in the template: 215 | 216 | ```vue 217 | 220 | ``` 221 | 222 | In other frameworks (but also in Vue), you can use the `import` statement to reference the asset: 223 | 224 | ```jsx 225 | import logo from '@/some-module/assets/logo.svg'; 226 | 227 | function MyComponent() { 228 | return My logo; 229 | } 230 | ``` 231 | 232 | CSS URL can be resolved in two ways. You can use the documented in the Apostrophe docs `some-module/public` folder and `/modules/some-module/font.ttf` URL where your file is located in `./modules/some-module/public/font.ttf` 233 | 234 | ```css 235 | @font-face { 236 | font-family: MyFont; 237 | src: url("/modules/some-module/font.ttf") format("truetype"); 238 | } 239 | ``` 240 | 241 | Or you can use the absolute sources root path `/src/some-module/fonts/font.ttf` where your file is located in `./modules/some-module/ui/fonts/font.ttf`. You can inspect the sources of your project that are copied in the central location `apos-build/@postrophecms/vite/default` directory. This is the root that Vite uses to resolve the paths and build the project. 242 | 243 | ```css 244 | @font-face { 245 | font-family: Inter; 246 | src: url("/src/some-module/fonts/font.ttf") format("truetype"); 247 | } 248 | ``` 249 | 250 | The same rules apply to paths in the `url()` function in CSS files. 251 | 252 | ## Configuring Your Code Editor 253 | 254 | Every editor, that understands the `jsconfig.json` or `tsconfig.json` file, can be configured to understand the `@/` alias provided by this module. Here is an example of a `jsconfig.json` file that you can place in your project root: 255 | 256 | ```json 257 | { 258 | "compilerOptions": { 259 | "baseUrl": "./apos-build/@apostrophecms/vite/default", 260 | "paths": { 261 | "@/*": ["./src/*"] 262 | }, 263 | "module": "ESNext", 264 | "moduleResolution": "bundler" 265 | }, 266 | "exclude": [ 267 | "apos-build/@apostrophecms/vite/default/dist", 268 | "node_modules", 269 | "public", 270 | "data" 271 | ] 272 | } 273 | ``` 274 | 275 | > Note: If you change your project asset namespace you have to adjust the `baseUrl` and `exclude` path accordingly. For example, if your project namespace is `my-namespace`, the `baseUrl` should be `./apos-build/@apostrophecms/vite/my-namespace` and the `exclude` path - `apos-build/@apostrophecms/vite/my-namespace/dist`. 276 | 277 | > Note: If you follow the import in your editor (e.g. Ctrl + Click in VSCode) it will lead to the `apos-build` directory and NOT the original source code. This is because the `apos-build` directory contains a copy of the entire project source code (including Admin UI) from all modules (local and npm) and is the actual source directory used by Vite to build the project. 278 | 279 | ## Extending the Vite Configuration 280 | 281 | You can customize the Vite configuration for your ApostropheCMS project in two ways: 282 | 283 | ### 1. Via Any Module `build.vite` Property 284 | 285 | Use this approach to configure Vite settings within individual ApostropheCMS modules: 286 | 287 | ```javascript 288 | // modules/some-module/index.js 289 | module.exports = { 290 | build: { 291 | vite: { 292 | myViteConfig: { 293 | // Standard Vite configuration 294 | define: { 295 | __MY_ENV__: '1', 296 | }, 297 | } 298 | }, 299 | }, 300 | }; 301 | ``` 302 | 303 | ### 2. Via Project Configuration File 304 | 305 | For project-wide Vite configuration, create one of these files in your project root: 306 | - `apos.vite.config.js` (for ESM projects) 307 | - `apos.vite.config.mjs` (for CommonJS projects) 308 | 309 | This method supports the full Vite configuration API and applies to your project's UI build. You can import Vite's configuration utilities directly from the ApostropheCMS Vite module: 310 | 311 | ```javascript 312 | // apos.vite.config.js 313 | import { defineConfig } from '@apostrophecms/vite/vite'; 314 | import vue from '@vitejs/plugin-vue' 315 | 316 | export default defineConfig({ 317 | plugins: [ vue() ] 318 | }); 319 | ``` 320 | 321 | The configuration format follows the standard [Vite configuration options](https://vitejs.dev/config/). Common use cases include adding plugins, defining environment variables, and customizing build settings. 322 | 323 | > Note: All Vite configurations are merged sequentially - first across modules (following module registration order, with later modules taking precedence), and finally with the project configuration file, which takes ultimate precedence. 324 | 325 | ## Limitations and Known Issues 326 | 327 | ### Hot Module Replacement 328 | - HMR only monitors existing `anyModule/ui` directories. If you add a new `ui` directory to a module, restart the server to enable HMR for that module. With default ApostropheCMS starter kits using `nodemon`, simply type `rs` in the terminal and press Enter. 329 | - The `apos` HMR won't work when the `public` build contains Vue sources (transformed by the `@vitejs/plugin-vue` plugin). The HMR for the `public` build should still work as expected. The problem is related to the fact that the page would contain two Vue instances (core and reactive) instances, which is not currently supported. We are researching solutions to this issue. 330 | 331 | ### Public Assets 332 | - Changes to `ui/public` directories don't trigger HMR or page reloads as they require a process restart 333 | - Workaround: Add `ui/public/` folders to your `nodemon` watch list in either `nodemon.json` or `package.json` 334 | - Future support for this feature will depend on user needs 335 | 336 | ### Vite Alias Resolution 337 | - When setting custom `resolve.alias` in Vite configuration, paths must resolve to the appropriate `apos-build/...` source code rather than the original source 338 | - Future enhancement planned: We will provide templating (e.g., `{srcRoot}`) or function arguments (e.g., `aposRoot`) to simplify correct path resolution 339 | 340 | ## Code Migration Guidelines 341 | 342 | ### Import Paths 343 | - Remove all `~` prefixes from CSS/Sass imports 344 | ```css 345 | /* Instead of: @import "~normalize.css" */ 346 | @import "normalize.css" 347 | ``` 348 | 349 | ### ApostropheCMS Module Imports 350 | - **Recommended**: Use the `Modules/module-name/components/...` alias instead of direct paths like `apostrophe/modules/module-name/ui/apos/components/...` 351 | - This alias is available only for `apos` source code; project code can define its own aliases 352 | 353 | ### Module System 354 | - Use only ESM syntax in UI source code: 355 | - ✅ `import abc from 'xxx'` or `const abc = await import('xxx')` 356 | - ✅ `export default ...` or `export something` 357 | - ❌ No CommonJS: `require()`, `module.exports`, `exports.xxx` 358 | -------------------------------------------------------------------------------- /test/vite.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('node:assert/strict'); 2 | const fs = require('fs-extra'); 3 | const path = require('node:path'); 4 | const t = require('apostrophe/test-lib/util.js'); 5 | 6 | const getAppConfig = (modules = {}) => { 7 | return { 8 | '@apostrophecms/express': { 9 | options: { 10 | session: { secret: 'supersecret' } 11 | } 12 | }, 13 | '@apostrophecms/vite': { 14 | options: { 15 | alias: 'vite' 16 | }, 17 | before: '@apostrophecms/asset' 18 | }, 19 | ...modules 20 | }; 21 | }; 22 | 23 | describe('@apostrophecms/vite', function () { 24 | let apos; 25 | 26 | this.timeout(t.timeout); 27 | 28 | after(async function () { 29 | return t.destroy(apos); 30 | }); 31 | 32 | describe('init', function () { 33 | before(async function () { 34 | await t.destroy(apos); 35 | apos = await t.create({ 36 | root: module, 37 | testModule: true, 38 | autoBuild: false, 39 | modules: getAppConfig() 40 | }); 41 | }); 42 | it('should have vite enabled', function () { 43 | const actual = { 44 | isViteEnabled: Object.keys(apos.modules).includes('@apostrophecms/vite'), 45 | buildModuleAlias: apos.asset.getBuildModuleAlias(), 46 | buildModuleConfigName: apos.asset.getBuildModuleConfig().name 47 | }; 48 | 49 | const expected = { 50 | isViteEnabled: true, 51 | buildModuleAlias: 'vite', 52 | buildModuleConfigName: '@apostrophecms/vite' 53 | }; 54 | 55 | assert.deepEqual(actual, expected); 56 | }); 57 | }); 58 | 59 | describe('specs', function () { 60 | before(async function () { 61 | await t.destroy(apos); 62 | apos = await t.create({ 63 | root: module, 64 | testModule: true, 65 | autoBuild: false, 66 | modules: getAppConfig() 67 | }); 68 | }); 69 | 70 | it('should apply manifest', async function () { 71 | const manifest = { 72 | // Circular dependency with `bar.js` 73 | '_shared-dependency.js': { 74 | file: 'assets/shared-dependency.js', 75 | name: 'shared-dependency', 76 | css: [ 77 | 'assets/shared-dependency.css' 78 | ], 79 | dynamicImports: [ 'bar.js' ] 80 | }, 81 | 'modules/asset/images/background.png': { 82 | file: 'assets/background.png', 83 | src: 'modules/asset/images/background.png' 84 | }, 85 | 'baz.js': { 86 | file: 'assets/baz.js', 87 | name: 'baz', 88 | src: 'baz.js', 89 | imports: [ 90 | '_shared-dependency.js' 91 | ], 92 | css: [ 93 | 'assets/baz.css' 94 | ], 95 | isDynamicEntry: true 96 | }, 97 | // Circular dependency with `shared-dependency.js` 98 | 'bar.js': { 99 | file: 'assets/bar.js', 100 | name: 'bar', 101 | src: 'bar.js', 102 | imports: [ 103 | '_shared-dependency.js' 104 | ], 105 | css: [ 106 | 'assets/bar.css' 107 | ], 108 | isDynamicEntry: true 109 | }, 110 | 'src/apos.js': { 111 | file: 'apos-build.js', 112 | name: 'apos', 113 | src: 'src/apos.js', 114 | isEntry: true, 115 | css: [ 116 | 'assets/apos.css' 117 | ] 118 | }, 119 | 'src/src.js': { 120 | file: 'src-build.js', 121 | name: 'src', 122 | src: 'src/src.js', 123 | isEntry: true, 124 | css: [ 125 | 'assets/src.css' 126 | ], 127 | assets: [ 128 | 'assets/background.png' 129 | ], 130 | dynamicImports: [ 'baz.js' ] 131 | }, 132 | 'src/article.js': { 133 | file: 'article-build.js', 134 | name: 'article', 135 | src: 'src/article.js', 136 | imports: [ 137 | '_shared-dependency.js' 138 | ], 139 | css: [ 140 | 'assets/article.css' 141 | ], 142 | isEntry: true 143 | }, 144 | 'src/tools.js': { 145 | file: 'tools-build.js', 146 | name: 'tools', 147 | src: 'src/tools.js', 148 | isEntry: true 149 | } 150 | }; 151 | 152 | const entrypoints = [ 153 | { 154 | name: 'src', 155 | type: 'index' 156 | }, 157 | { 158 | name: 'article', 159 | type: 'custom' 160 | }, 161 | { 162 | name: 'tools', 163 | type: 'custom' 164 | }, 165 | { 166 | name: 'apos', 167 | type: 'apos' 168 | }, 169 | { 170 | name: 'public', 171 | type: 'bundled' 172 | } 173 | ]; 174 | 175 | const actual = await apos.vite.applyManifest(entrypoints, manifest); 176 | const expected = [ 177 | { 178 | name: 'src', 179 | type: 'index', 180 | manifest: { 181 | root: 'dist', 182 | files: { 183 | js: [ 'src-build.js' ], 184 | css: [ 185 | 'assets/src.css', 186 | 'assets/baz.css', 187 | 'assets/shared-dependency.css', 188 | 'assets/bar.css' 189 | ], 190 | assets: [ 'assets/background.png' ], 191 | imports: [], 192 | dynamicImports: [ 'assets/baz.js' ] 193 | }, 194 | src: { js: [ 'src/src.js' ] }, 195 | devServer: false 196 | } 197 | }, 198 | { 199 | name: 'article', 200 | type: 'custom', 201 | manifest: { 202 | root: 'dist', 203 | files: { 204 | js: [ 'article-build.js' ], 205 | css: [ 206 | 'assets/article.css', 207 | 'assets/shared-dependency.css', 208 | 'assets/bar.css' 209 | ], 210 | assets: [], 211 | imports: [ 'assets/shared-dependency.js' ], 212 | dynamicImports: [] 213 | }, 214 | src: { js: [ 'src/article.js' ] }, 215 | devServer: false 216 | } 217 | }, 218 | { 219 | name: 'tools', 220 | type: 'custom', 221 | manifest: { 222 | root: 'dist', 223 | files: { 224 | js: [ 'tools-build.js' ], 225 | css: [], 226 | assets: [], 227 | imports: [], 228 | dynamicImports: [] 229 | }, 230 | src: { js: [ 'src/tools.js' ] }, 231 | devServer: false 232 | } 233 | }, 234 | { 235 | name: 'apos', 236 | type: 'apos', 237 | manifest: { 238 | root: 'dist', 239 | files: { 240 | js: [ 'apos-build.js' ], 241 | css: [ 'assets/apos.css' ], 242 | assets: [], 243 | imports: [], 244 | dynamicImports: [] 245 | }, 246 | src: { js: [ 'src/apos.js' ] }, 247 | devServer: false 248 | } 249 | }, 250 | { 251 | name: 'public', 252 | type: 'bundled' 253 | } 254 | ]; 255 | 256 | assert.deepEqual(actual, expected); 257 | }); 258 | 259 | describe('isHostnameAllowed', function () { 260 | it('should allow localhost by default', function () { 261 | assert(apos.vite.isHostnameAllowed('localhost', undefined)); 262 | assert(apos.vite.isHostnameAllowed('localhost:3000', undefined)); 263 | }); 264 | 265 | it('should allow 127.0.0.1 by default', function () { 266 | assert(apos.vite.isHostnameAllowed('127.0.0.1', undefined)); 267 | assert(apos.vite.isHostnameAllowed('127.0.0.1:3000', undefined)); 268 | }); 269 | 270 | it('should allow ::1 (IPv6) by default', function () { 271 | assert(apos.vite.isHostnameAllowed('[::1]:3000', undefined)); 272 | }); 273 | 274 | it('should reject custom hostname by default', function () { 275 | assert(!apos.vite.isHostnameAllowed('example.com', undefined)); 276 | assert(!apos.vite.isHostnameAllowed('example.com:3000', undefined)); 277 | }); 278 | 279 | it('should allow exact match in allowedHosts', function () { 280 | assert(apos.vite.isHostnameAllowed('example.com', [ 'example.com' ])); 281 | assert(apos.vite.isHostnameAllowed('example.com:3000', [ 'example.com' ])); 282 | }); 283 | 284 | it('should support wildcard patterns', function () { 285 | assert(apos.vite.isHostnameAllowed('sub.example.com', [ '.example.com' ])); 286 | assert(apos.vite.isHostnameAllowed('example.com', [ '.example.com' ])); 287 | assert(!apos.vite.isHostnameAllowed('notexample.com', [ '.example.com' ])); 288 | }); 289 | 290 | it('should allow all when allowedHosts is true', function () { 291 | assert(apos.vite.isHostnameAllowed('anything.com', true)); 292 | assert(apos.vite.isHostnameAllowed('192.168.1.1', true)); 293 | }); 294 | 295 | it('should handle IPv6 with port in brackets', function () { 296 | assert(apos.vite.isHostnameAllowed('[::1]:3000', [ '::1' ])); 297 | assert(apos.vite.isHostnameAllowed('[2001:db8::1]:3000', [ '2001:db8::1' ])); 298 | }); 299 | 300 | it('should handle IPv6 without port', function () { 301 | assert(apos.vite.isHostnameAllowed('[::1]', [ '::1' ])); 302 | assert(apos.vite.isHostnameAllowed('[2001:db8::1]', [ '2001:db8::1' ])); 303 | }); 304 | 305 | it('should return true for empty hostname', function () { 306 | assert(apos.vite.isHostnameAllowed('', undefined)); 307 | assert(apos.vite.isHostnameAllowed(null, undefined)); 308 | }); 309 | 310 | it('should check multiple allowed hosts', function () { 311 | const allowed = [ 'example.com', 'test.local', 'localhost' ]; 312 | assert(apos.vite.isHostnameAllowed('example.com', allowed)); 313 | assert(apos.vite.isHostnameAllowed('test.local', allowed)); 314 | assert(apos.vite.isHostnameAllowed('localhost', allowed)); 315 | assert(!apos.vite.isHostnameAllowed('notallowed.com', allowed)); 316 | }); 317 | 318 | it('should handle credentials in hostname', function () { 319 | assert(apos.vite.isHostnameAllowed('user:pass@localhost', undefined)); 320 | assert(apos.vite.isHostnameAllowed('user:pass@localhost:3000', undefined)); 321 | assert(apos.vite.isHostnameAllowed('user:pass@example.com', [ 'example.com' ])); 322 | assert(apos.vite.isHostnameAllowed('user:pass@[::1]:3000', undefined)); 323 | assert(apos.vite.isHostnameAllowed('user:pass@[2001:db8::1]:3000', [ '2001:db8::1' ])); 324 | }); 325 | }); 326 | }); 327 | 328 | describe('Build', function () { 329 | before(async function () { 330 | await t.destroy(apos); 331 | apos = await t.create({ 332 | root: module, 333 | testModule: true, 334 | autoBuild: false, 335 | modules: getAppConfig(getBuildModules()) 336 | }); 337 | }); 338 | it('should copy source files and generate entrypoints', async function () { 339 | const build = async () => { 340 | await apos.vite.reset(); 341 | apos.vite.currentSourceMeta = await apos.vite 342 | .computeSourceMeta({ copyFiles: true }); 343 | const entrypoints = apos.asset.getBuildEntrypoints(); 344 | await apos.vite.createImports(entrypoints); 345 | }; 346 | await build(); 347 | const rootDirSrc = apos.vite.buildRootSource; 348 | const meta = apos.vite.currentSourceMeta; 349 | 350 | const aposStat = await fs.stat(path.join(rootDirSrc, 'apos.js')); 351 | const srcStat = await fs.stat(path.join(rootDirSrc, 'src.js')); 352 | 353 | assert.ok(aposStat.isFile()); 354 | assert.ok(srcStat.isFile()); 355 | 356 | // Assert meta entries 357 | const coreModule = '@apostrophecms/admin-bar'; 358 | const coreModuleOverride = '@apostrophecms/my-admin-bar'; 359 | const aposContent = await fs.readFile(path.join(rootDirSrc, 'apos.js'), 'utf8'); 360 | const srcContent = await fs.readFile(path.join(rootDirSrc, 'src.js'), 'utf8'); 361 | 362 | { 363 | const entry = meta.find((entry) => entry.id === coreModule); 364 | assert.ok(entry); 365 | assert.ok(entry.files.includes('src/index.js')); 366 | assert.ok(entry.files.includes('apos/components/TheAposAdminBar.vue')); 367 | assert.ok(entry.files.includes('apos/apps/AposAdminBar.js')); 368 | } 369 | 370 | { 371 | const entry = meta.find((entry) => entry.id === coreModuleOverride); 372 | assert.ok(entry); 373 | assert.ok(entry.files.includes('src/index.js')); 374 | assert.ok(entry.files.includes('apos/apps/AposAdminBar.js')); 375 | } 376 | 377 | // I. Test sources overrides 378 | // 1. from the core admin-bar module 379 | const adminBarAppContent = await fs.readFile( 380 | path.join(rootDirSrc, coreModule, 'apos', 'apps', 'AposAdminBar.js'), 381 | 'utf8' 382 | ); 383 | // 2. from the core admin-bar module 384 | const adminBarSrcContent = await fs.readFile( 385 | path.join(rootDirSrc, coreModule, 'src', 'index.js'), 386 | 'utf8' 387 | ); 388 | // 3. from the admin-bar-component module 389 | const adminBarComponentContent = await fs.readFile( 390 | path.join(rootDirSrc, 'admin-bar-component', 'apos', 'components', 'TheAposAdminBar.vue'), 391 | 'utf8' 392 | ); 393 | assert.match(adminBarAppContent, /console\.log\('AposAdminBar\.js'\);/); 394 | assert.match(adminBarSrcContent, /console\.log\('src\/index\.js'\);/); 395 | assert.match(adminBarComponentContent, /

The Apos Admin Bar<\/h1>/); 396 | 397 | // II. Core Entrypoints 398 | // 1. src.js 399 | { 400 | const match = srcContent.match(/"[^"]+\/@apostrophecms\/admin-bar\/ui\/src\/index.js";/g); 401 | assert.equal(match?.length, 1, 'The core admin-bar module should be imported once'); 402 | } 403 | // 2. apos.js 404 | { 405 | const match = aposContent.match( 406 | /import TheAposAdminBar from "[^"]+\/admin-bar-component\/ui\/apos\/components\/TheAposAdminBar\.vue";/g 407 | ); 408 | assert.equal(match?.length, 1, 'TheAposAdminBar.vue component override should be imported once'); 409 | } 410 | { 411 | const match = aposContent.match( 412 | /window\.apos\.vueComponents\["TheAposAdminBar"\] = TheAposAdminBar;/g 413 | ); 414 | assert.equal(match?.length, 1, 'TheAposAdminBar.vue component should be registered once'); 415 | } 416 | { 417 | const match = aposContent.match( 418 | /import AposAdminBar_[\w\d]+ from "[^"]+\/@apostrophecms\/admin-bar\/ui\/apos\/apps\/AposAdminBar\.js";/g 419 | ); 420 | assert.equal(match?.length, 1, 'AposAdminBar.js App import should be present once'); 421 | } 422 | { 423 | const match = aposContent.match( 424 | /AposAdminBar_[\d]+App\(\);/g 425 | ); 426 | assert.equal(match?.length, 1, 'AposAdminBar.js App should be called once'); 427 | } 428 | assert.match( 429 | aposContent, 430 | /import AposCommandMenuKey from "[^"]+\/@apostrophecms\/command-menu\/ui\/apos\/components\/AposCommandMenuKey\.vue";/ 431 | ); 432 | assert.match( 433 | aposContent, 434 | /import Link from "[^"]+\/@apostrophecms\/rich-text-widget\/ui\/apos\/tiptap-extensions\/Link\.js";/ 435 | ); 436 | 437 | // III. Extra Build Entrypoints & Rebundle Modules 438 | const articleEntryContent = await fs.readFile( 439 | path.join(rootDirSrc, 'article.js'), 440 | 'utf8' 441 | ); 442 | 443 | assert(articleEntryContent.includes('article-page/ui/src/main.scss')); 444 | assert(articleEntryContent.includes('article-page/ui/src/index.js')); 445 | assert(articleEntryContent.includes('article-page/ui/src/main.js')); 446 | 447 | const toolsEntryContent = await fs.readFile( 448 | path.join(rootDirSrc, 'tools.js'), 449 | 'utf8' 450 | ); 451 | 452 | assert(toolsEntryContent.includes('selected-article-widget/ui/src/tabs.js')); 453 | 454 | { 455 | const match = srcContent.match( 456 | /import topic_\d+App from "[^"]+\/@apostrophecms\/home-page\/ui\/src\/topic\.js";/g 457 | ); 458 | assert.equal(match?.length, 1, 'home-page topic.js should be imported once'); 459 | } 460 | { 461 | const match = srcContent.match( 462 | /import main_\d+App from "[^"]+\/@apostrophecms\/home-page\/ui\/src\/main\.js";/g 463 | ); 464 | assert.equal(match?.length, 1, 'home-page main.js should be imported once'); 465 | } 466 | { 467 | const match = srcContent.match( 468 | /import topic_\d+App from "[^"]+\/article-widget\/ui\/src\/topic\.js";/g 469 | ); 470 | assert.equal(match?.length, 1, 'article-widget topic.js should be imported once'); 471 | } 472 | { 473 | const match = srcContent.match( 474 | /import carousel_\d+App from "[^"]+\/article-widget\/ui\/src\/carousel\.js";/g 475 | ); 476 | assert.equal(match?.length, 1, 'article-widget carousel.js should be imported once'); 477 | } 478 | }); 479 | 480 | it('should copy public bundled assets', async function () { 481 | const build = async () => { 482 | await apos.vite.reset(); 483 | apos.vite.currentSourceMeta = await apos.vite 484 | .computeSourceMeta({ copyFiles: true }); 485 | const entrypoints = apos.asset.getBuildEntrypoints(); 486 | await apos.vite.createImports(entrypoints); 487 | }; 488 | const rootDir = apos.vite.buildRoot; 489 | 490 | await build(); 491 | 492 | { 493 | const stat = await fs.stat(path.join(rootDir, 'public.js')); 494 | const content = await fs.readFile(path.join(rootDir, 'public.js'), 'utf8'); 495 | 496 | const expected = 'console.log(\'public/article.js\');console.log(\'public/nested/article.js\');'; 497 | const actual = content.replace(/\s/g, ''); 498 | 499 | assert.ok(stat.isFile()); 500 | assert.equal(actual, expected, 'unexpected public.js content'); 501 | } 502 | 503 | { 504 | const stat = await fs.stat(path.join(rootDir, 'public.css')); 505 | const content = await fs.readFile(path.join(rootDir, 'public.css'), 'utf8'); 506 | 507 | const expected = '.article-main{margin:0;}.article-nested-main{margin:0;}'; 508 | const actual = content.replace(/\s/g, ''); 509 | 510 | assert.ok(stat.isFile()); 511 | assert.equal(actual, expected, 'unexpected public.css content'); 512 | } 513 | }); 514 | 515 | it('should build', async function () { 516 | await apos.vite.reset(); 517 | await apos.task.invoke('@apostrophecms/asset:build', { 518 | 'check-apos-build': false 519 | }); 520 | 521 | const bundleDir = apos.asset.getBundleRootDir(); 522 | assert.ok(fs.existsSync(path.join(bundleDir, '.manifest.json'))); 523 | assert.ok(fs.existsSync(path.join(bundleDir, 'apos-bundle.css'))); 524 | assert.ok(fs.existsSync(path.join(bundleDir, 'apos-module-bundle.js'))); 525 | assert.ok(fs.existsSync(path.join(bundleDir, 'apos-public-module-bundle.js'))); 526 | assert.ok(fs.existsSync(path.join(bundleDir, 'apos-src-module-bundle.js'))); 527 | assert.ok(fs.existsSync(path.join(bundleDir, 'article-module-bundle.js'))); 528 | assert.ok(fs.existsSync(path.join(bundleDir, 'public-module-bundle.js'))); 529 | assert.ok(fs.existsSync(path.join(bundleDir, 'public-src-module-bundle.js'))); 530 | assert.ok(fs.existsSync(path.join(bundleDir, 'tools-module-bundle.js'))); 531 | }); 532 | }); 533 | }); 534 | 535 | function getBuildModules(assetOptions = {}) { 536 | return { 537 | '@apostrophecms/asset': { 538 | options: { 539 | rebundleModules: { 540 | 'article-page': 'article', 541 | 'article-widget': 'main', 542 | 'selected-article-widget:tabs': 'tools', 543 | '@apostrophecms/my-home-page:main': 'main' 544 | }, 545 | ...assetOptions 546 | } 547 | }, 548 | 'admin-bar-component': {}, 549 | '@apostrophecms/home-page': { 550 | build: { 551 | vite: { 552 | bundles: { 553 | topic: {}, 554 | main: {} 555 | } 556 | } 557 | } 558 | }, 559 | article: { 560 | extend: '@apostrophecms/piece-type', 561 | init() {} 562 | }, 563 | 'article-page': { 564 | build: { 565 | vite: { 566 | bundles: { 567 | main: {} 568 | } 569 | } 570 | } 571 | }, 572 | 'article-widget': { 573 | build: { 574 | vite: { 575 | bundles: { 576 | topic: {}, 577 | carousel: {} 578 | } 579 | } 580 | } 581 | }, 582 | 'selected-article-widget': { 583 | build: { 584 | vite: { 585 | bundles: { 586 | tabs: {} 587 | } 588 | } 589 | } 590 | } 591 | }; 592 | } 593 | -------------------------------------------------------------------------------- /lib/internals.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const fs = require('fs-extra'); 3 | const postcssrc = require('postcss-load-config'); 4 | const postcssViewportToContainerToggle = require('postcss-viewport-to-container-toggle'); 5 | const viteBaseConfig = require('./vite-base-config'); 6 | const viteAposConfig = require('./vite-apos-config'); 7 | const vitePublicConfig = require('./vite-public-config'); 8 | const viteServeConfig = require('./vite-serve-config'); 9 | const vitePostcssConfig = require('./vite-postcss-config'); 10 | 11 | module.exports = (self) => { 12 | return { 13 | async initWhenReady() { 14 | self.isDebug = self.apos.asset.isDebugMode(); 15 | self.buildRoot = self.apos.asset.getBuildRootDir(); 16 | self.buildRootSource = path.join(self.buildRoot, self.buildSourceFolderName); 17 | self.distRoot = path.join(self.buildRoot, self.distFolderName); 18 | self.cacheDirBase = path.join( 19 | self.apos.rootDir, 20 | 'data/temp', 21 | self.apos.asset.getNamespace(), 22 | 'vite' 23 | ); 24 | 25 | const publicRel = '.public/manifest.json'; 26 | const aposRel = '.apos/manifest.json'; 27 | self.buildManifestPath = { 28 | publicRel, 29 | aposRel, 30 | public: path.join(self.distRoot, publicRel), 31 | apos: path.join(self.distRoot, aposRel) 32 | }; 33 | 34 | self.userConfigFile = path.join(self.apos.rootDir, 'apos.vite.config.mjs'); 35 | if (!fs.existsSync(self.userConfigFile)) { 36 | self.userConfigFile = path.join(self.apos.rootDir, 'apos.vite.config.js'); 37 | } 38 | if (!fs.existsSync(self.userConfigFile)) { 39 | self.userConfigFile = null; 40 | } 41 | 42 | await fs.mkdir(self.buildRootSource, { recursive: true }); 43 | }, 44 | 45 | printDebug(id, ...args) { 46 | if (self.isDebug) { 47 | self.logDebug('vite-' + id, ...args); 48 | } 49 | }, 50 | 51 | async buildBefore(options = {}) { 52 | if (options.isTask) { 53 | await self.reset(); 54 | } 55 | self.currentSourceMeta = await self.computeSourceMeta({ 56 | copyFiles: true 57 | }); 58 | const entrypoints = self.apos.asset.getBuildEntrypoints(options.types); 59 | self.ensureInitEntry(entrypoints); 60 | self.applyModulePreloadPolyfill(entrypoints, options); 61 | await self.createImports(entrypoints); 62 | 63 | // Copy the public files so that Vite is not complaining about missing files 64 | // while building the project. 65 | try { 66 | await fs.copy( 67 | path.join(self.apos.asset.getBundleRootDir(), 'modules'), 68 | path.join(self.buildRoot, 'modules') 69 | ); 70 | } catch (_) { 71 | // do nothing 72 | } 73 | }, 74 | 75 | // Builds the apos UI assets. 76 | async buildApos(options) { 77 | const execute = await self.shouldBuild('apos', options); 78 | 79 | if (!execute) { 80 | return; 81 | } 82 | 83 | self.printLabels('apos', true); 84 | const { build, config } = await self.getViteBuild('apos', options); 85 | self.printDebug('build-apos', { viteConfig: config }); 86 | await build(config); 87 | self.printLabels('apos', false); 88 | 89 | return Date.now(); 90 | }, 91 | 92 | // Builds the public assets. 93 | async buildPublic(options) { 94 | if (self.getBuildEntrypointsFor('public').length === 0) { 95 | return false; 96 | } 97 | // It's OK because it will execute once if no manifest and dev server is on. 98 | if (options.devServer === 'public') { 99 | const execute = await self.shouldBuild('public', options); 100 | if (!execute) { 101 | return; 102 | } 103 | } 104 | self.printLabels('public', true); 105 | const { build, config } = await self.getViteBuild('public', options); 106 | self.printDebug('build-public', { viteConfig: config }); 107 | await build(config); 108 | self.printLabels('public', false); 109 | }, 110 | 111 | // Create an entrypoint configuration for the vite client. 112 | getViteClientEntrypoint(scenes) { 113 | return { 114 | name: 'vite', 115 | type: 'bundled', 116 | scenes, 117 | outputs: [ 'js' ], 118 | manifest: { 119 | root: '', 120 | files: {}, 121 | src: { 122 | js: [ '@vite/client' ] 123 | }, 124 | devServer: true 125 | } 126 | }; 127 | }, 128 | 129 | getCurrentMode(devServer) { 130 | let currentBuild; 131 | const currentScenes = []; 132 | if (devServer === 'apos') { 133 | currentBuild = 'public'; 134 | currentScenes.push('apos'); 135 | } 136 | if (devServer === 'public') { 137 | currentBuild = 'apos'; 138 | currentScenes.push('public', 'apos'); 139 | } 140 | 141 | return { 142 | build: currentBuild, 143 | scenes: currentScenes 144 | }; 145 | }, 146 | 147 | // Assesses if the apos build should be triggered. 148 | async shouldBuild(id, options) { 149 | // No work for the current build. 150 | if (self.getBuildEntrypointsFor(id).length === 0) { 151 | return false; 152 | } 153 | // Build tasks always run. Also dev forced build. 154 | if (options.isTask || process.env.APOS_DEV === '1') { 155 | return true; 156 | } 157 | if (!self.hasViteBuildManifest(id)) { 158 | return true; 159 | } 160 | 161 | // Detect last apos build time and compare it with the last system change. 162 | const aposManifest = await self.apos.asset.loadSavedBuildManifest(); 163 | const lastBuildMs = aposManifest.ts || 0; 164 | const lastSystemChange = await self.apos.asset.getSystemLastChangeMs(); 165 | if (lastSystemChange !== false && lastBuildMs > lastSystemChange) { 166 | return false; 167 | } 168 | 169 | // Forced build by type. Keeping the core current logic. 170 | // In play when asset option `publicBundle: false` is set - forces apos build 171 | // if not cached. 172 | if (options.types?.includes(id)) { 173 | return true; 174 | } 175 | 176 | return true; 177 | }, 178 | 179 | // The CLI info labels for the build process. 180 | printLabels(id, before) { 181 | const phrase = before ? 'apostrophe:assetTypeBuilding' : 'apostrophe:assetTypeBuildComplete'; 182 | const req = self.apos.task.getReq(); 183 | const labels = [ ...new Set( 184 | self.getBuildEntrypointsFor(id).map(e => req.t(e.label)) 185 | ) ]; 186 | 187 | if (labels.length) { 188 | self.apos.util.log( 189 | req.t(phrase, { label: labels.join(', ') }) 190 | ); 191 | } 192 | }, 193 | 194 | // Build the index that we use when watching the original source files for changes. 195 | buildWatchIndex() { 196 | self.currentSourceMeta.forEach((entry, index) => { 197 | self.currentSourceUiIndex[entry.dirname] = index; 198 | entry.files.forEach((file) => { 199 | self.currentSourceFsIndex[path.join(entry.dirname, file)] = index; 200 | self.currentSourceRelIndex.set( 201 | file, 202 | (self.currentSourceRelIndex.get(file) ?? new Set()) 203 | .add(index) 204 | ); 205 | }); 206 | }); 207 | }, 208 | 209 | // Build a watcher voter object to detect what entrypoints are 210 | // concerned with a given source file change. 211 | setWatchVoters(entrypoints) { 212 | self.entrypointWatchVoters = {}; 213 | for (const entrypoint of entrypoints) { 214 | self.entrypointWatchVoters[entrypoint.name] = (relSourcePath, rootPath) => { 215 | if ( 216 | self.apos.asset.getEntrypointManger(entrypoint) 217 | .match(relSourcePath, rootPath) 218 | ) { 219 | return entrypoint; 220 | } 221 | return null; 222 | }; 223 | } 224 | self.entrypointsManifest 225 | // TODO: should be `entrypoint.bundled === true` in the future. 226 | .filter((entrypoint) => entrypoint.type === 'bundled') 227 | .forEach((entrypoint) => { 228 | self.entrypointWatchVoters[entrypoint.name] = (relSourcePath, rootPath) => { 229 | if ( 230 | self.apos.asset.getEntrypointManger(entrypoint) 231 | .match(relSourcePath, rootPath) 232 | ) { 233 | return entrypoint; 234 | } 235 | return null; 236 | }; 237 | }); 238 | 239 | }, 240 | 241 | getChangedEntrypointsFor(relSourcePath, metaEntry) { 242 | return Object.values(self.entrypointWatchVoters) 243 | .map((voter) => voter(relSourcePath, metaEntry)) 244 | .filter((entrypoint) => entrypoint !== null); 245 | }, 246 | 247 | getChangedBundledEntrypointsFor(relSourcePath, metaEntry) { 248 | const bundled = self.entrypointsManifest 249 | // TODO: should be `entrypoint.bundled === true` in the future. 250 | .filter((entrypoint) => entrypoint.type === 'bundled') 251 | .map((entrypoint) => self.entrypointWatchVoters[entrypoint.name]) 252 | .filter((voter) => !!voter); 253 | 254 | return bundled 255 | .map((voter) => voter(relSourcePath, metaEntry)) 256 | .filter((entrypoint) => entrypoint !== null); 257 | }, 258 | 259 | getRootPath(onChangePath) { 260 | return path.join(self.apos.npmRootDir, onChangePath); 261 | }, 262 | onSourceAdd(filePath, isDir) { 263 | if (isDir) { 264 | return; 265 | } 266 | const p = self.getRootPath(filePath); 267 | const key = Object.keys(self.currentSourceUiIndex) 268 | .filter((dir) => p.startsWith(dir)) 269 | .reduce((acc, dir) => { 270 | // Choose the best match - the longest string wins 271 | if (dir.length > acc.length) { 272 | return dir; 273 | } 274 | return acc; 275 | }, ''); 276 | const index = self.currentSourceUiIndex[key]; 277 | const entry = self.currentSourceMeta[index]; 278 | 279 | if (!entry) { 280 | return; 281 | } 282 | const file = p.replace(entry.dirname + '/', ''); 283 | entry.files.push(file); 284 | entry.files = Array.from(new Set(entry.files)); 285 | 286 | // Add the new file to the absolute and relative index 287 | self.currentSourceRelIndex.set( 288 | file, 289 | (self.currentSourceRelIndex.get(file) ?? new Set()) 290 | .add(index) 291 | ); 292 | self.currentSourceFsIndex[p] = index; 293 | 294 | // Copy the file to the build source 295 | self.onSourceChange(filePath, true); 296 | 297 | // Recreate the imports for the changed entrypoints. 298 | const entrypoints = self.getChangedEntrypointsFor(file, entry); 299 | // and re-create the imports with suppressed errors 300 | self.createImports(entrypoints, true); 301 | 302 | // Below is a future implementation of bundled entrypoint restart. 303 | // Restart the process if we have a bundled entrypoint change. 304 | // TODO: should be `entrypoint.bundled === true` in the future. 305 | // if (entrypoints.some(e => e.type === 'bundled')) { 306 | // self.apos.asset.forcePageReload(); 307 | // } 308 | }, 309 | onSourceChange(filePath, silent = false) { 310 | const p = self.getRootPath(filePath); 311 | // grab every source file that "looks like" the changed file 312 | const entry = self.currentSourceMeta[self.currentSourceFsIndex[p]]; 313 | const sources = entry?.files.filter((file) => p.endsWith(file)); 314 | if (!sources?.length) { 315 | return; 316 | } 317 | for (const source of sources) { 318 | self.currentSourceRelIndex.get(source)?.forEach((index) => { 319 | try { 320 | const target = path.join( 321 | self.buildRootSource, 322 | self.currentSourceMeta[index].name, source 323 | ); 324 | fs.mkdirpSync(path.dirname(target)); 325 | fs.copyFileSync( 326 | path.join(self.currentSourceMeta[index].dirname, source), 327 | target 328 | ); 329 | } catch (e) { 330 | if (silent) { 331 | return; 332 | } 333 | self.apos.util.error( 334 | `Failed to copy file "${source}" from module ${self.currentSourceMeta[index]?.name}`, 335 | e.message 336 | ); 337 | } 338 | }); 339 | }; 340 | 341 | // Below is a future implementation of bundled entrypoint restart. 342 | // Not supported at the moment because: 343 | // - we copy properly all bundled assets as a single file to the `apos-build/... 344 | // root folder. 345 | // - we do not have a reliable way to copy that file to the bundle root 346 | // with the appropriate bundle name (public/apos-frontend/...). 347 | // - this can be solved with a separate core handler that does only that 348 | // (it's tricky). 349 | // if (silent) { 350 | // return; 351 | // } 352 | 353 | // // After we are done with copying the files, check for process restart. 354 | // for (const source of sources) { 355 | // const entrypoints = self.getChangedBundledEntrypointsFor(source, entry); 356 | // const hasBundledChange = entrypoints.some((e) => e.type === 'bundled'); 357 | // if (hasBundledChange) { 358 | // self.apos.asset.forcePageReload(); 359 | // return; 360 | // } 361 | // } 362 | }, 363 | onSourceUnlink(filePath, isDir) { 364 | if (isDir) { 365 | return; 366 | } 367 | const p = self.getRootPath(filePath); 368 | const source = self.currentSourceMeta[self.currentSourceFsIndex[p]] 369 | ?.files.find((file) => p.endsWith(file)); 370 | if (!source) { 371 | return; 372 | } 373 | const index = self.currentSourceFsIndex[p]; 374 | 375 | // 1. Delete the source file from the build source 376 | try { 377 | fs.unlinkSync( 378 | path.join( 379 | self.buildRootSource, 380 | self.currentSourceMeta[index].name, 381 | source 382 | ) 383 | ); 384 | } catch (e) { 385 | self.apos.util.error( 386 | `[onSourceUnlink] Failed to unlink file "${source}" from module ${self.currentSourceMeta[index]?.name}`, 387 | e.message 388 | ); 389 | } 390 | 391 | // 2. Remove the file reference from the indexes 392 | self.currentSourceRelIndex.get(source)?.delete(index); 393 | delete self.currentSourceFsIndex[p]; 394 | 395 | // 3. Recreate the imports for the changed entrypoints. 396 | const entrypoints = self.getChangedEntrypointsFor( 397 | source, 398 | self.currentSourceMeta[index] 399 | ); 400 | // and update the meta entry 401 | self.currentSourceMeta[index].files = 402 | self.currentSourceMeta[index].files 403 | .filter((file) => file !== source); 404 | // and re-create the imports with suppressed errors 405 | self.createImports(entrypoints, true); 406 | 407 | // Below is a future implementation of bundled entrypoint restart. 408 | // 4. Restart the process if we have a bundled entrypoint change. 409 | // TODO: should be `entrypoint.bundled === true` in the future. 410 | // if (entrypoints.some(e => e.type === 'bundled')) { 411 | // self.apos.asset.forcePageReload(); 412 | // return; 413 | // } 414 | 415 | // 3. Trigger a silent change, so that if there is an override/parent file 416 | // it will be copied to the build source. 417 | self.onSourceChange(filePath, true); 418 | }, 419 | 420 | // Get the base URL for the dev server. 421 | // If an entrypoint `type` is is provided, a check against the current build options 422 | // will be performed and appropriate values will be returned. 423 | getDevServerUrl() { 424 | return self.apos.asset.getBaseMiddlewareUrl() + '/__vite'; 425 | }, 426 | 427 | // We need to know if a dev server should be used for an entrypoint 428 | // when attaching the apos manifest. 429 | hasDevServerUrl(type) { 430 | if (!self.buildOptions.devServer) { 431 | return false; 432 | } 433 | if (type === 'bundled') { 434 | return false; 435 | } 436 | if (type === 'apos' && self.buildOptions.devServer === 'public') { 437 | return false; 438 | } 439 | if (type && type !== 'apos' && self.buildOptions.devServer === 'apos') { 440 | return false; 441 | } 442 | 443 | return true; 444 | }, 445 | 446 | // Create a vite instance. This can be called only when we have 447 | // a running express server. See handlers `afterListen`. 448 | async createViteInstance(options) { 449 | const { createServer } = await import('vite'); 450 | const viteConfig = await self.getViteConfig(options.devServer, options, 'serve'); 451 | 452 | const instance = await createServer(viteConfig); 453 | self.viteDevInstance = instance; 454 | 455 | self.apos.util.log( 456 | `HMR for "${options.devServer}" started` 457 | ); 458 | 459 | self.printDebug('dev-middleware', { viteConfig }); 460 | }, 461 | 462 | // Compute metadata for the source files of all modules using 463 | // the core asset handler. Optionally copy the files to the build 464 | // source and write the metadata to a JSON file. 465 | async computeSourceMeta({ copyFiles = false } = {}) { 466 | const options = { 467 | modules: self.apos.asset.getRegisteredModules(), 468 | stats: true 469 | }; 470 | if (copyFiles) { 471 | options.asyncHandler = async (entry) => { 472 | for (const file of entry.files) { 473 | await fs.copy( 474 | path.join(entry.dirname, file), 475 | path.join(self.buildRootSource, entry.name, file) 476 | ); 477 | } 478 | }; 479 | } 480 | // Do not bother with modules that are only "virtual" and do not have 481 | // any files to process. 482 | return (await self.apos.asset.computeSourceMeta(options)) 483 | .filter((entry) => entry.exists); 484 | }, 485 | 486 | // Generate the import files for all entrypoints and the pre-build manifest. 487 | // `suppressErrors` is used to skip errors in the build process. 488 | async createImports(entrypoints, suppressErrors = false) { 489 | for (const entrypoint of entrypoints) { 490 | if (entrypoint.condition === 'nomodule') { 491 | self.apos.util.warnDev( 492 | `The entrypoint "${entrypoint.name}" is marked as "nomodule". ` + 493 | 'This is not supported by Vite and will be skipped.' 494 | ); 495 | continue; 496 | } 497 | if (entrypoint.type === 'bundled') { 498 | await self.copyExternalBundledAsset(entrypoint); 499 | continue; 500 | } 501 | const output = await self.getEntrypointOutput(entrypoint, suppressErrors); 502 | await self.apos.asset.writeEntrypointFile(output); 503 | 504 | if (!self.entrypointsManifest.some((e) => e.name === entrypoint.name)) { 505 | self.entrypointsManifest.push({ 506 | ...entrypoint, 507 | manifest: self.toManifest(entrypoint) 508 | }); 509 | } 510 | } 511 | }, 512 | 513 | // Copy and concatenate the externally bundled assets. 514 | async copyExternalBundledAsset(entrypoint) { 515 | if (entrypoint.type !== 'bundled') { 516 | return; 517 | } 518 | const filesByOutput = self.apos.asset.getEntrypointManger(entrypoint) 519 | .getSourceFiles(self.currentSourceMeta); 520 | const manifestFiles = {}; 521 | for (const [ output, files ] of Object.entries(filesByOutput)) { 522 | if (!files.length) { 523 | continue; 524 | } 525 | const raw = files 526 | .map(({ path: filePath }) => fs.readFileSync(filePath, 'utf8')) 527 | .join('\n'); 528 | 529 | await self.apos.asset.writeEntrypointFile({ 530 | importFile: path.join(self.buildRoot, `${entrypoint.name}.${output}`), 531 | prologue: entrypoint.prologue, 532 | raw 533 | }); 534 | manifestFiles[output] = manifestFiles[output] || []; 535 | manifestFiles[output].push(`${entrypoint.name}.${output}`); 536 | } 537 | self.entrypointsManifest.push({ 538 | ...entrypoint, 539 | manifest: self.toManifest(entrypoint, manifestFiles) 540 | }); 541 | }, 542 | 543 | async getEntrypointOutput(entrypoint, suppressErrors = false) { 544 | const manager = self.apos.asset.getEntrypointManger(entrypoint); 545 | 546 | // synthetic entrypoints are not processed, they only provide 547 | // a way to inject additional code (prologue) into the build. 548 | const files = entrypoint.synthetic 549 | ? entrypoint.outputs?.reduce((acc, ext) => ({ 550 | ...acc, 551 | [ext]: [] 552 | }), {}) 553 | : manager.getSourceFiles( 554 | self.currentSourceMeta, 555 | { composePath: self.composeSourceImportPath } 556 | ); 557 | 558 | const output = await manager.getOutput(files, { 559 | modules: self.apos.asset.getRegisteredModules(), 560 | suppressErrors 561 | }); 562 | output.importFile = path.join(self.buildRootSource, `${entrypoint.name}.js`); 563 | 564 | return output; 565 | }, 566 | 567 | // Esnure there is always an `index` entrypoint, that holds the 568 | // prologue and the scenes, required for the polyfill. 569 | // The created synthetic entrypoint will only include the prologue. 570 | ensureInitEntry(entrypoints) { 571 | const exists = entrypoints.some((entry) => entry.type === 'index'); 572 | if (exists) { 573 | return entrypoints; 574 | } 575 | const first = self.apos.asset.getBuildEntrypoints() 576 | .find((entry) => entry.type === 'index'); 577 | 578 | const index = { 579 | name: 'synth-src', 580 | type: 'index', 581 | // Synthetic entrypoints are not built, they only provide 582 | // a way to inject additional code (prologue) into the build. 583 | synthetic: true, 584 | label: first.label, 585 | scenes: first.scenes, 586 | inputs: [], 587 | outputs: [ 'js' ], 588 | condition: first.condition, 589 | prologue: first.prologue, 590 | ignoreSources: [], 591 | sources: { 592 | js: [], 593 | scss: [] 594 | } 595 | }; 596 | entrypoints.unshift(index); 597 | 598 | return entrypoints; 599 | }, 600 | 601 | // Ensure Vite client is injected as a first entrypoint. 602 | // This should be called after the `ensureInitEntry` method, 603 | // basically as a last step. The method will add the Vite client 604 | // entrypoint only if needed. 605 | ensureViteClientEntry(entrypoints, scenes, buildOptions) { 606 | if (buildOptions.devServer && !entrypoints.some((entry) => entry.name === 'vite')) { 607 | entrypoints.unshift(self.getViteClientEntrypoint(scenes)); 608 | } 609 | }, 610 | 611 | // Add vite module preload polyfill to the first `index` entrypoint. 612 | // We can probably remove it soon as the browser support looks good: 613 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/modulepreload#browser_compatibility 614 | // 615 | // The polyfill will be skipped for external frontends. External frontends 616 | // are responsible for including the polyfill themselves if needed. 617 | applyModulePreloadPolyfill(entrypoints, buildOptions) { 618 | if (!buildOptions.modulePreloadPolyfill) { 619 | return; 620 | } 621 | const first = entrypoints.find((entry) => entry.type === 'index'); 622 | first.prologue = (first.prologue || '') + 623 | '\nimport \'vite/modulepreload-polyfill\';'; 624 | }, 625 | 626 | // Adds `manifest` property (object) to the entrypoint after a build. 627 | // See apos.asset.configureBuildModule() for more information. 628 | // This method needs a Vite manifest in order to transform it to the 629 | // format that is required by the asset module. 630 | async applyManifest(entrypoints, viteManifest) { 631 | const result = []; 632 | for (const entrypoint of entrypoints) { 633 | const manifest = Object.values(viteManifest) 634 | .find((entry) => entry.isEntry && entry.name === entrypoint.name); 635 | 636 | // The entrypoint type `bundled` is not processed by Vite. 637 | if (!manifest) { 638 | result.push(entrypoint); 639 | continue; 640 | } 641 | 642 | const convertFn = (ref) => viteManifest[ref].file; 643 | const css = [ 644 | ...manifest.css || [], 645 | ...getFiles({ 646 | manifest: viteManifest, 647 | entry: manifest, 648 | sources: [ 'imports', 'dynamicImports' ], 649 | target: 'css' 650 | }) 651 | ]; 652 | const assets = [ 653 | ...manifest.assets || [], 654 | ...getFiles({ 655 | manifest: viteManifest, 656 | entry: manifest, 657 | sources: [ 'imports', 'dynamicImports' ], 658 | target: 'assets' 659 | }) 660 | ]; 661 | const imports = [ 662 | ...manifest.imports?.map(convertFn) ?? [], 663 | ...getFiles({ 664 | manifest: viteManifest, 665 | entry: manifest, 666 | convertFn, 667 | sources: [ 'imports' ], 668 | target: 'imports' 669 | }) 670 | ]; 671 | const dynamicImports = [ 672 | ...manifest.dynamicImports?.map(convertFn) ?? [], 673 | ...getFiles({ 674 | manifest: viteManifest, 675 | entry: manifest, 676 | convertFn, 677 | sources: [ 'dynamicImports' ], 678 | target: 'dynamicImports' 679 | }) 680 | ]; 681 | entrypoint.manifest = { 682 | root: self.distFolderName, 683 | files: { 684 | js: [ manifest.file ], 685 | css, 686 | assets, 687 | imports, 688 | dynamicImports 689 | }, 690 | src: { 691 | js: [ manifest.src ] 692 | }, 693 | devServer: self.hasDevServerUrl(entrypoint.type) 694 | }; 695 | result.push(entrypoint); 696 | } 697 | 698 | function defaultConvertFn(ref) { 699 | return ref; 700 | } 701 | function getFiles({ 702 | manifest, entry, data, sources, target, convertFn = defaultConvertFn 703 | }, acc = [], seen = {}) { 704 | if (Array.isArray(data)) { 705 | acc.push(...data.map(convertFn)); 706 | } 707 | for (const source of sources) { 708 | if (!Array.isArray(entry?.[source])) { 709 | continue; 710 | } 711 | entry[source].forEach(ref => { 712 | if (seen[`${source}-${ref}`]) { 713 | return; 714 | } 715 | seen[`${source}-${ref}`] = true; 716 | manifest[ref] && getFiles({ 717 | manifest, 718 | entry: manifest[ref], 719 | data: manifest[ref][target], 720 | sources, 721 | target, 722 | convertFn 723 | }, acc, seen); 724 | }); 725 | } 726 | return acc; 727 | } 728 | 729 | return result; 730 | }, 731 | 732 | // Accepts an entrypoint and optional files object and returns a manifest-like object. 733 | // This handler is used in the initializing phase of the build process. 734 | // In scenarios where the module build is not tirggered at all 735 | // (e.g. boot in production), 736 | // the core system will use its own saved manifest to identify the files that has to 737 | // be injected in the browser. This manifest is mostly used in development (especially 738 | // the `devServer` property) when a build for given entrypoint is not triggered 739 | // (because this entrypoint is served by the dev server). 740 | toManifest(entrypoint, files) { 741 | if (entrypoint.type === 'bundled') { 742 | const result = { 743 | root: '', 744 | files: { 745 | js: files?.js || [], 746 | css: files?.css || [], 747 | assets: [], 748 | imports: [], 749 | dynamicImports: [] 750 | }, 751 | // Bundled entrypoints are not served by the dev server. 752 | src: null, 753 | devServer: false 754 | }; 755 | if (result.files.js.length || result.files.css.length) { 756 | return result; 757 | } 758 | return null; 759 | } 760 | return { 761 | root: self.distFolderName, 762 | files: { 763 | js: [], 764 | css: [], 765 | assets: [], 766 | imports: [], 767 | dynamicImports: [] 768 | }, 769 | // This can be extended, for now we only support JS entries. 770 | // It's used to inject the entrypoint into the HTML. 771 | src: { 772 | js: [ path.join(self.buildSourceFolderName, `${entrypoint.name}.js`) ] 773 | }, 774 | devServer: self.hasDevServerUrl(entrypoint.type) 775 | }; 776 | }, 777 | 778 | // Get the build manifest produced by Vite build for the current run. 779 | // If `id` is provided, it will return the manifest for the given ID. 780 | // Possible values are `public` and `apos`. 781 | async getViteBuildManifest(id) { 782 | let apos = {}; 783 | let pub = {}; 784 | if (!id || id === 'apos') { 785 | try { 786 | apos = await fs.readJson(self.buildManifestPath.apos); 787 | } catch (e) { 788 | apos = {}; 789 | } 790 | } 791 | if (!id || id === 'public') { 792 | try { 793 | pub = await fs.readJson(self.buildManifestPath.public); 794 | } catch (e) { 795 | pub = {}; 796 | } 797 | } 798 | 799 | return { 800 | ...apos, 801 | ...pub 802 | }; 803 | }, 804 | 805 | // `id` is `public` or `apos` 806 | hasViteBuildManifest(id) { 807 | return fs.existsSync(self.buildManifestPath[id]); 808 | }, 809 | 810 | // Filter the entrypoints for different devServer scenarios in development. 811 | // The build option `devServer` can be `public` or `apos`. We want to filter 812 | // the entrypoints based on that. 813 | // `id` is `public` or `apos` 814 | // TODO: filtering `bundled` by type does not scale well. We need to introduce 815 | // a `bundled: Boolean` property to the entrypoint configuration in the future. 816 | // Also we might need a specific `buildTag` (or better name) that corresponds 817 | // to `devServer` and `types` (can be string 'public' or 'apos'). This will allow us 818 | // to introduce new entrypoint types and features without breaking the current logic. 819 | getBuildEntrypointsFor(id) { 820 | if (id === 'apos') { 821 | return self.entrypointsManifest 822 | .filter((entrypoint) => entrypoint.type === 'apos'); 823 | } 824 | if (id === 'public') { 825 | return self.entrypointsManifest 826 | .filter((entrypoint) => ![ 'bundled', 'apos' ].includes(entrypoint.type)); 827 | } 828 | throw new Error(`Invalid build ID "${id}"`); 829 | }, 830 | 831 | // Return the configuration and the vite build function for a given build scenario. 832 | // `id` is `public` or `apos` 833 | async getViteBuild(id, options) { 834 | const { build } = await import('vite'); 835 | const config = await self.getViteConfig(id, options); 836 | return { 837 | build, 838 | config 839 | }; 840 | }, 841 | 842 | // Get the Inline Vite configuration for a given build scenario. 843 | // https://vite.dev/guide/api-javascript.html#inlineconfig 844 | // This is the high level method that should be used to get the Vite configuration. 845 | // `id` is `public` or `apos`. 846 | // `options` is build options. 847 | // `command` is `build` or `serve`. 848 | async getViteConfig(id, options, command = 'build') { 849 | const env = { 850 | command, 851 | mode: self.apos.asset.isProductionMode() ? 'production' : 'development' 852 | }; 853 | const baseConfig = await self.getBaseViteConfig(id, options, env); 854 | 855 | /** @type {import('vite').UserConfig} */ 856 | let resolved; 857 | if (id === 'public') { 858 | resolved = await (await self.getPublicViteConfig(baseConfig))(env); 859 | } 860 | if (id === 'apos') { 861 | resolved = await (await self.getAposViteConfig(baseConfig))(env); 862 | } 863 | 864 | if (!resolved) { 865 | throw new Error(`Invalid Vite config ID "${id}"`); 866 | } 867 | 868 | return self.getFinalViteConfig(id, options, resolved, env); 869 | }, 870 | 871 | // Return the input configuration for the Vite build for a given build scenario. 872 | // `id` is `public` or `apos`. 873 | getBuildInputs(id) { 874 | return Object.fromEntries( 875 | self.getBuildEntrypointsFor(id) 876 | .map((entrypoint) => ([ 877 | entrypoint.name, 878 | path.join(self.buildRootSource, `${entrypoint.name}.js`) 879 | ])) 880 | ); 881 | }, 882 | 883 | /** 884 | * Get the base Vite (user) configuration, used in all other configurations. 885 | * 886 | * @param {string} id `public` or `apos` 887 | * @param {object} options build options 888 | * @param {import('vite').ConfigEnv} env vite config environment 889 | * @returns {Promise} 890 | */ 891 | async getBaseViteConfig(id, options, env) { 892 | return viteBaseConfig({ 893 | mode: env.mode, 894 | base: self.apos.asset.getAssetBaseUrl(), 895 | root: self.buildRoot, 896 | cacheDir: path.join(self.cacheDirBase, id), 897 | manifestRelPath: self.buildManifestPath[`${id}Rel`], 898 | sourceMaps: options.sourcemaps, 899 | assetOptions: self.apos.asset.options 900 | }); 901 | }, 902 | 903 | /** 904 | * Get the vite (user) configuration for the `apos` build. 905 | * Return a function that accepts Vite Environment object and 906 | * returns the merged Vite config. 907 | * 908 | * @param {import('vite').UserConfig} baseConfig 909 | * @returns {Promise< 910 | * (configEnv: import('vite').ConfigEnv) => Promise 911 | * >} 912 | */ 913 | async getAposViteConfig(baseConfig) { 914 | const vite = await import('vite'); 915 | const config = await viteAposConfig({ 916 | sourceRoot: self.buildRootSource, 917 | input: self.getBuildInputs('apos') 918 | }); 919 | const postcssConfig = await self.getPostcssConfig(self.buildOptions, 'apos'); 920 | const aposConfig = vite.mergeConfig(config, postcssConfig); 921 | 922 | const mergeConfigs = vite.defineConfig((configEnv) => { 923 | return vite.mergeConfig(baseConfig, aposConfig, true); 924 | }); 925 | 926 | return mergeConfigs; 927 | }, 928 | 929 | /** 930 | * Get the vite (user) configuration for the `public` build. 931 | * Return a function that accepts Vite Environment object and returns 932 | * the merged Vite config. 933 | * The project level configuration provided by modules and a root level 934 | * `apos.vite.config.js` will be merged with the base and public configurations. 935 | * 936 | * 937 | * @param {import('vite').UserConfig} baseConfig 938 | * @return {Promise< 939 | * (configEnv: import('vite').ConfigEnv) => Promise 940 | * >} 941 | */ 942 | async getPublicViteConfig(baseConfig) { 943 | const vite = await import('vite'); 944 | // The base public config 945 | const config = await vitePublicConfig({ 946 | sourceRoot: self.buildRootSource, 947 | input: self.getBuildInputs('public') 948 | }); 949 | const postcssConfig = await self.getPostcssConfig(self.buildOptions, 'public'); 950 | const publicConfig = vite.mergeConfig(config, postcssConfig); 951 | const mergeConfigs = vite.defineConfig(async (configEnv) => { 952 | // Module configurations 953 | let merged = vite.mergeConfig(baseConfig, publicConfig); 954 | for (const { extensions, name } of self.getBuildEntrypointsFor('public')) { 955 | if (!extensions) { 956 | continue; 957 | } 958 | for (const [ key, value ] of Object.entries(extensions)) { 959 | self.apos.asset.printDebug('public-config-merge', `[${name}] merging "${key}"`, { 960 | entrypoint: name, 961 | [key]: value 962 | }); 963 | merged = vite.mergeConfig(merged, value); 964 | } 965 | } 966 | 967 | // The `apos.vite.config.js` at the project root can be used to extend 968 | // the public config. 969 | const userConfig = self.userConfigFile 970 | ? (await vite.loadConfigFromFile( 971 | configEnv, 972 | self.userConfigFile, 973 | self.apos.rootDir, 974 | 'silent' 975 | ))?.config || {} 976 | : {}; 977 | 978 | merged = vite.mergeConfig(merged, userConfig); 979 | 980 | return merged; 981 | }); 982 | 983 | return mergeConfigs; 984 | }, 985 | 986 | /** 987 | * Gets postcss config for the current environment * 988 | * 989 | * @param {object} buildOptions: build options 990 | * @param {string} id: apos / public 991 | * 992 | * @returns {Promise} 993 | */ 994 | async getPostcssConfig(buildOptions, id) { 995 | const { 996 | enable: enablePostcssViewport, ...postcssViewportOptions 997 | } = buildOptions?.postcssViewportToContainerToggle || {}; 998 | 999 | const postcssPlugins = []; 1000 | if (id === 'public') { 1001 | try { 1002 | const { 1003 | plugins 1004 | } = await postcssrc({}, self.apos.rootDir); 1005 | postcssPlugins.push(...plugins); 1006 | } catch (err) { /* Project has no postcss config file */ } 1007 | 1008 | if (enablePostcssViewport) { 1009 | postcssPlugins.push(postcssViewportToContainerToggle(postcssViewportOptions)); 1010 | } 1011 | } 1012 | 1013 | if (id === 'apos') { 1014 | postcssPlugins.push( 1015 | require('autoprefixer')() 1016 | ); 1017 | } 1018 | 1019 | return vitePostcssConfig({ plugins: postcssPlugins }); 1020 | }, 1021 | 1022 | /** 1023 | * Accepts merged vite User configuration and produces 1024 | * the final Vite Inline configuration. 1025 | * 1026 | * @param {string} id `public` or `apos` 1027 | * @param {object} buildOptions build options 1028 | * @param {import('vite').InlineConfig} baseConfig 1029 | * @param {import('vite').ConfigEnv} env vite config environment 1030 | * @returns {Promise} 1031 | */ 1032 | async getFinalViteConfig(id, buildOptions, baseConfig, env) { 1033 | baseConfig.configFile = false; 1034 | baseConfig.envFile = false; 1035 | 1036 | if (env.command === 'build') { 1037 | return baseConfig; 1038 | } 1039 | 1040 | const { mergeConfig } = await import('vite'); 1041 | const serveConfig = await viteServeConfig({ 1042 | app: self.apos.app, 1043 | httpServer: self.apos.modules['@apostrophecms/express'].server, 1044 | hasHMR: buildOptions.hmr, 1045 | hmrPort: buildOptions.hmrPort 1046 | }); 1047 | 1048 | return mergeConfig(baseConfig, serveConfig); 1049 | } 1050 | }; 1051 | }; 1052 | --------------------------------------------------------------------------------