├── 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 |
2 | The Apos Admin Bar
3 |
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 |
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 |
218 |
219 |
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
;
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 |
--------------------------------------------------------------------------------