Migrate Webpack to Vite
Deploy Spot / deploy (push) Successful in 22s

This commit is contained in:
2026-07-26 15:33:07 +02:00
parent fb500f3c34
commit 7da504ca6b
10 changed files with 943 additions and 3809 deletions
-172
View File
@@ -1,172 +0,0 @@
const path = require('path');
const fs = require('fs');
const webpack = require('webpack');
const SymlinkWebpackPlugin = require('symlink-webpack-plugin');
const { VueLoaderPlugin } = require('vue-loader');
const CopyPlugin = require("copy-webpack-plugin");
const ROOT = path.resolve(__dirname, '..');
const SRC = path.resolve(ROOT, 'src');
const PUBLIC = path.resolve(ROOT, 'public');
module.exports = (env, argv) => {
const mode = argv.mode || 'production';
const isDev = (mode === 'development');
return {
mode,
devtool: isDev ? 'inline-source-map' : false,
watch: isDev,
entry: {
app: path.resolve(SRC, 'app.js')
},
output: {
path: PUBLIC,
filename: isDev ? 'assets/[name].js' : 'assets/[name].[contenthash:8].js',
chunkFilename: isDev ? 'assets/[name].js' : 'assets/[name].[contenthash:8].js',
publicPath: './',
clean: isDev ? false : {
keep: /^(index\.php|files|geo|assets\/images\/icons)(\/.*)?$/
}
},
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
maplibre: {
test: /[\\/]node_modules[\\/]maplibre-gl[\\/]/,
name: 'maplibre',
chunks: 'all',
priority: 30,
enforce: true
},
uppy: {
test: /[\\/]node_modules[\\/](@uppy|@transloadit|namespace-emitter|nanoid)[\\/]/,
name: 'uppy',
chunks: 'async',
priority: 20,
enforce: true
}
}
}
},
performance: {
hints: isDev ? false : 'warning',
maxEntrypointSize: 1500 * 1024,
maxAssetSize: 1100 * 1024
},
module: {
rules: [{
test: /\.vue$/,
loader: 'vue-loader'
}, {
test: /\.mjs$/,
include: /node_modules\/maplibre-gl/,
parser: {
exprContextCritical: false, // Silences expression context warnings [2]
unknownContextCritical: false // Silences unknown context warnings
}
}, {
test: /\.js$/,
exclude: file => (/node_modules/.test(file) && !/\.vue\.js/.test(file)),
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}, {
test: /\.s[ac]ss$/i,
use: [
'vue-style-loader',
'css-loader',
{
loader: 'sass-loader',
options: {
implementation: require('sass'),
sourceMap: isDev
}
}
]
}, {
test: /\.css$/i,
use: ['vue-style-loader', 'css-loader']
}, {
test: /\.(png|svg|jpg|jpeg|gif|webp)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 1 * 1024
}
},
generator: {
filename: isDev ? 'assets/images/[name][ext]' : 'assets/images/[name].[contenthash:8][ext]'
}
}
]
},
plugins: [
new SymlinkWebpackPlugin([
{ origin: '../files/', symlink: 'files' },
{ origin: '../resources/geo/', symlink: 'geo' },
{ origin: '../src/images/icons/', symlink: 'assets/images/icons' }
]),
new CopyPlugin({
patterns: [{
from: "node_modules/maplibre-gl/dist/maplibre-gl-(worker|shared).mjs",
to: "maplibre/[name][ext]"
}]
}),
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: 'true',
__VUE_PROD_DEVTOOLS__: 'false',
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false'
}),
{
apply(compiler) {
compiler.hooks.done.tap('EntryPointManifestPlugin', (stats) => {
const manifest = {
entrypoints: mapChunkGroups(stats.compilation.entrypoints),
chunkGroups: mapChunkGroups(stats.compilation.chunkGroups)
};
const manifestPath = path.resolve(PUBLIC, 'assets', 'entrypoints.json');
const tmpManifestPath = `${manifestPath}.tmp`;
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
fs.writeFileSync(tmpManifestPath, JSON.stringify(manifest, null, '\t'));
fs.renameSync(tmpManifestPath, manifestPath);
});
}
},
new VueLoaderPlugin()
],
resolve: {
extensions: ['.vue', '.scss', '...'],
alias: {
'@components': path.resolve(SRC, 'components'),
'@images': path.resolve(SRC, 'images'),
'@scripts': path.resolve(SRC, 'scripts'),
'@styles': path.resolve(SRC, 'styles')
}
},
stats: {
children: true,
errorDetails: true
}
};
};
function mapChunkGroups(chunkGroups = {}) {
const chunkGroupEntries = (chunkGroups instanceof Map)?Array.from(chunkGroups.entries()):Array.from(chunkGroups).map((chunkGroup) => [chunkGroup.name, chunkGroup]);
return Object.fromEntries(
chunkGroupEntries
.filter(([name]) => name)
.map(([name, chunkGroup]) => [
name,
Array.from((typeof chunkGroup.getFiles === 'function')?chunkGroup.getFiles():chunkGroup.assets)
.map((asset) => (typeof asset === 'string')?asset:asset.name)
.filter((file) => file.endsWith('.js'))
])
);
}
+47 -4
View File
@@ -44,6 +44,7 @@ class Spot extends Main
const PROJECT_NAME = 'LiveTrail'; const PROJECT_NAME = 'LiveTrail';
const MAIN_PAGE = 'index'; const MAIN_PAGE = 'index';
const VITE_APP = 'src/app.js';
private Project $oProject; private Project $oProject;
private Media $oMedia; private Media $oMedia;
@@ -165,6 +166,8 @@ class Spot extends Main
} }
public function getAppMainPage(string $sCsrfToken='') { public function getAppMainPage(string $sCsrfToken='') {
$asViteAssets = $this->getViteAssets();
return parent::getMainPage( return parent::getMainPage(
array( array(
'projects' => $this->oProject->getProjects(), 'projects' => $this->oProject->getProjects(),
@@ -186,18 +189,58 @@ class Spot extends Main
'tags' => [ 'tags' => [
'language' => $this->oLang->getLanguage(), 'language' => $this->oLang->getLanguage(),
'title' => self::PROJECT_NAME, 'title' => self::PROJECT_NAME,
'app_entry' => $asViteAssets['app']
], ],
'instances' => [ 'instances' => [
'entrypoint' => $this->getAppEntryPoints() 'css' => $asViteAssets['css'],
'module' => $asViteAssets['module']
] ]
) )
); );
} }
private function getAppEntryPoints() { private function getViteAssets() {
$asManifest = json_decode(file_get_contents(__DIR__.'/../public/.vite/manifest.json'), true);
$asAppImport = $asManifest[self::VITE_APP];
//Recursive search for chunk imports
$asImports = array();
$asSeenImports = array(self::VITE_APP => true);
$this->appendViteImportedChunks($asManifest, $asAppImport, $asSeenImports, $asImports);
//CSS
$asCssFiles = array();
foreach(array_merge(array($asAppImport), $asImports) as $asChunk) {
foreach($asChunk['css'] ?? array() as $sCssFile) $asCssFiles[] = $sCssFile;
}
//Modules
$asModuleFiles = array();
foreach($asImports as $asImport) {
if(str_ends_with($asImport['file'] ?? '', '.js')) $asModuleFiles[] = $asImport['file'];
}
return array(
'app' => $asAppImport['file'],
'css' => $this->getViteAssetInstances($asCssFiles),
'module' => $this->getViteAssetInstances($asModuleFiles)
);
}
private function appendViteImportedChunks($asManifest, $asChunk, &$asSeenImports, &$asImports) {
foreach($asChunk['imports'] ?? array() as $sImport) {
if(isset($asSeenImports[$sImport]) || !isset($asManifest[$sImport])) continue;
$asSeenImports[$sImport] = true;
$this->appendViteImportedChunks($asManifest, $asManifest[$sImport], $asSeenImports, $asImports);
$asImports[] = $asManifest[$sImport];
}
}
private function getViteAssetInstances($asFilePaths) {
return array_map( return array_map(
function($sFileName) {return ['filename' => self::addTimestampToFilePath($sFileName)];}, function($sFilePath) { return array('filename' => $sFilePath); },
json_decode(file_get_contents('assets/entrypoints.json'), true)['entrypoints']['app'] $asFilePaths
); );
} }
+746 -3609
View File
File diff suppressed because it is too large Load Diff
+5 -14
View File
@@ -1,13 +1,7 @@
{ {
"devDependencies": { "devDependencies": {
"@babel/core": "^8.0.1", "@vitejs/plugin-vue": "^6.0.8",
"@babel/preset-env": "^8.0.2", "vite": "^8.1.5"
"babel-loader": "^10.0.0",
"copy-webpack-plugin": "^14.0.0",
"symlink-webpack-plugin": "^1.1.0",
"vue-loader": "^17.4.2",
"webpack": "^5.99.7",
"webpack-cli": "^7.0.2"
}, },
"name": "spot", "name": "spot",
"description": "FindMeSpot & GPX integration", "description": "FindMeSpot & GPX integration",
@@ -15,8 +9,8 @@
"main": "index.js", "main": "index.js",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "flock -n node_modules/.webpack-build.lock webpack --config build/webpack.config.js --mode development", "dev": "vite build --mode development --watch",
"prod": "flock -n node_modules/.webpack-build.lock webpack --config build/webpack.config.js --mode production" "prod": "vite build"
}, },
"keywords": [], "keywords": [],
"author": "Franzz", "author": "Franzz",
@@ -27,13 +21,10 @@
"@uppy/core": "^5.2.0", "@uppy/core": "^5.2.0",
"@uppy/xhr-upload": "^5.2.0", "@uppy/xhr-upload": "^5.2.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"css-loader": "^7.1.2",
"maplibre-gl": "^6.0.0", "maplibre-gl": "^6.0.0",
"sass": "^1.97.2", "sass": "^1.97.2",
"sass-loader": "^17.0.0",
"simplebar-vue": "^2.3.3", "simplebar-vue": "^2.3.3",
"vue": "^3.5.40", "vue": "^3.5.40"
"vue-style-loader": "^4.1.3"
}, },
"allowScripts": { "allowScripts": {
"@parcel/watcher@2.6.0": true, "@parcel/watcher@2.6.0": true,
+3 -1
View File
@@ -34,12 +34,14 @@
## Web Root ## Web Root
The web server should serve `public/` as the application document root. PHP source, configuration, Composer dependencies, uploaded files, and GPX data stay outside the public tree; `public/index.php` is the front controller and webpack writes generated frontend assets to `public/assets/`. The web server should serve `public/` as the application document root. PHP source, configuration, Composer dependencies, uploaded files, and GPX data stay outside the public tree; `public/index.php` is the front controller and Vite writes generated frontend assets to `public/assets/`.
Runtime data is exposed through symlinks only: `public/files -> ../files` and `public/geo -> ../resources/geo`. The build must not copy uploaded media or GPX data into `public/`. Runtime data is exposed through symlinks only: `public/files -> ../files` and `public/geo -> ../resources/geo`. The build must not copy uploaded media or GPX data into `public/`.
## Local Development ## Local Development
`npm run dev` watches and builds development assets into `public/assets/`. `npm run prod` builds hashed production assets into `public/assets/`. PHP reads `public/.vite/manifest.json` and populates the CSS, module, and preload parts defined in `resources/masks/index.html` while rendering the request.
When developing Spot and the sibling `objects` library together, install dependencies through the local Composer manifest: When developing Spot and the sibling `objects` library together, install dependencies through the local Composer manifest:
```bash ```bash
+7 -3
View File
@@ -19,11 +19,15 @@
<meta name="theme-color" content="#081B19"> <meta name="theme-color" content="#081B19">
<script id="app-config" type="application/json">[#]app_config[#]</script> <script id="app-config" type="application/json">[#]app_config[#]</script>
<title>[#]title[#]</title> <title>[#]title[#]</title>
<!-- [PART] css [START] -->
<link rel="stylesheet" href="[#]filename[#]" />
<!-- [PART] css [END] -->
<!-- [PART] module [START] -->
<link rel="modulepreload" href="[#]filename[#]" />
<!-- [PART] module [END] -->
<script type="module" src="[#]app_entry[#]"></script>
</head> </head>
<body> <body>
<div id="container"></div> <div id="container"></div>
<!-- [PART] entrypoint [START] -->
<script defer src="[#]filename[#]"></script>
<!-- [PART] entrypoint [END] -->
</body> </body>
</html> </html>
+2 -2
View File
@@ -4,8 +4,8 @@ import Project from '@components/project';
const aoRoutes = { const aoRoutes = {
'project': Project, //Merge app.js and project.js calls to avoid extra http request on inital page 'project': Project, //Merge app.js and project.js calls to avoid extra http request on inital page
'admin': defineAsyncComponent(() => import(/* webpackChunkName: "admin" */ '@components/admin')), 'admin': defineAsyncComponent(() => import('@components/admin')),
'upload': defineAsyncComponent(() => import(/* webpackChunkName: "upload" */ '@components/upload')) 'upload': defineAsyncComponent(() => import('@components/upload'))
}; };
export default { export default {
+3 -2
View File
@@ -1,4 +1,5 @@
//Librairies //Librairies
import 'vite/modulepreload-polyfill';
import Api from '@scripts/api'; import Api from '@scripts/api';
import Lang from '@scripts/lang'; import Lang from '@scripts/lang';
import Projects from '@scripts/projects'; import Projects from '@scripts/projects';
@@ -6,10 +7,10 @@ import User from '@scripts/user';
import { createApp, reactive } from 'vue'; import { createApp, reactive } from 'vue';
//Main template //Main template
import App from './App'; import App from './App.vue';
//Style //Style
import Css from '@styles/spot'; import '@styles/spot.scss';
//App Configuration from PHP //App Configuration from PHP
const appConfig = JSON.parse(document.getElementById('app-config').textContent); const appConfig = JSON.parse(document.getElementById('app-config').textContent);
+3 -2
View File
@@ -1,5 +1,6 @@
<script> <script>
import { Map, Marker, LngLatBounds, LngLat, Popup, ScaleControl, NavigationControl, setWorkerUrl } from 'maplibre-gl'; import { Map, Marker, LngLatBounds, LngLat, Popup, ScaleControl, NavigationControl, setWorkerUrl } from 'maplibre-gl';
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { createApp } from 'vue'; import { createApp } from 'vue';
@@ -12,7 +13,7 @@ import ProjectPopup from '@components/projectPopup';
import ProjectFeed from '@components/projectFeed'; import ProjectFeed from '@components/projectFeed';
import ProjectSettings from '@components/projectSettings'; import ProjectSettings from '@components/projectSettings';
setWorkerUrl('maplibre/maplibre-gl-worker.mjs'); setWorkerUrl(maplibreWorkerUrl);
class GroupedScaleControl { class GroupedScaleControl {
constructor(options) { constructor(options) {
@@ -486,7 +487,7 @@ export default {
this.map.jumpTo({ this.map.jumpTo({
center: new LngLat(oDefaultProject.longitude, oDefaultProject.latitude), center: new LngLat(oDefaultProject.longitude, oDefaultProject.latitude),
zoom: Math.log2(iWorldSize / this.map.transform.tileSize), zoom: Math.log2(iWorldSize / 512),
pitch: 0, pitch: 0,
bearing: 0 bearing: 0
}); });
+127
View File
@@ -0,0 +1,127 @@
import vue from '@vitejs/plugin-vue';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
const ROOT = fileURLToPath(new URL('.', import.meta.url));
const SRC = path.resolve(ROOT, 'src');
const PUBLIC = path.resolve(ROOT, 'public');
export default defineConfig(({ mode }) => {
const isDev = (mode === 'development');
return {
base: './',
publicDir: false,
plugins: [
spotPublicAssets(),
vue()
],
build: {
outDir: PUBLIC,
assetsDir: 'assets',
emptyOutDir: false,
manifest: true,
sourcemap: isDev,
minify: !isDev,
cssMinify: !isDev,
assetsInlineLimit: 1 * 1024,
chunkSizeWarningLimit: 1500,
rolldownOptions: {
input: {
app: path.resolve(SRC, 'app.js')
},
output: {
entryFileNames: isDev ? 'assets/[name].js' : 'assets/[name].[hash].js',
chunkFileNames: isDev ? 'assets/[name].js' : 'assets/[name].[hash].js',
assetFileNames: (assetInfo) => {
const sourceName = assetInfo.names?.[0] || assetInfo.name || 'asset';
const extension = path.extname(sourceName).toLowerCase();
const isImage = ['.png', '.svg', '.jpg', '.jpeg', '.gif', '.webp'].includes(extension);
const folder = isImage ? 'assets/images' : 'assets';
return isDev ? `${folder}/[name][extname]` : `${folder}/[name].[hash][extname]`;
}
}
}
},
resolve: {
extensions: ['.mjs', '.js', '.vue', '.scss', '.json'],
alias: {
'@components': path.resolve(SRC, 'components'),
'@images': path.resolve(SRC, 'images'),
'@scripts': path.resolve(SRC, 'scripts'),
'@styles': path.resolve(SRC, 'styles')
}
},
define: {
__VUE_OPTIONS_API__: 'true',
__VUE_PROD_DEVTOOLS__: 'false',
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false'
}
};
});
function spotPublicAssets() {
return {
name: 'spot-public-assets',
apply: 'build',
buildStart() {
cleanGeneratedAssets();
ensurePublicSymlinks();
}
};
}
function cleanGeneratedAssets() {
fs.rmSync(path.resolve(PUBLIC, '.vite'), { recursive: true, force: true });
fs.rmSync(path.resolve(PUBLIC, 'maplibre'), { recursive: true, force: true });
const assetsDir = path.resolve(PUBLIC, 'assets');
if (fs.existsSync(assetsDir)) {
for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) {
const entryPath = path.resolve(assetsDir, entry.name);
if (entry.name === 'images' && entry.isDirectory()) {
cleanImagesDir(entryPath);
}
else {
fs.rmSync(entryPath, { recursive: true, force: true });
}
}
}
fs.mkdirSync(path.resolve(PUBLIC, 'assets', 'images'), { recursive: true });
}
function cleanImagesDir(imagesDir) {
for (const entry of fs.readdirSync(imagesDir, { withFileTypes: true })) {
if (entry.name === 'icons') continue;
fs.rmSync(path.resolve(imagesDir, entry.name), { recursive: true, force: true });
}
}
function ensurePublicSymlinks() {
ensureSymlink('../files', path.resolve(PUBLIC, 'files'));
ensureSymlink('../resources/geo', path.resolve(PUBLIC, 'geo'));
ensureSymlink('../../../src/images/icons', path.resolve(PUBLIC, 'assets', 'images', 'icons'));
}
function ensureSymlink(target, linkPath) {
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
try {
const stats = fs.lstatSync(linkPath);
if (stats.isSymbolicLink()) {
if (fs.readlinkSync(linkPath) === target) return;
fs.unlinkSync(linkPath);
}
else {
throw new Error(`Refusing to replace non-symlink public path: ${linkPath}`);
}
}
catch (error) {
if (error.code !== 'ENOENT') throw error;
}
fs.symlinkSync(target, linkPath);
}