0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-01 00:16:25 +02:00
gitea/vite.config.ts
silverwind 612ce46cda
Fix theme discovery and Vite dev server in dev mode (#37033)
1. In dev mode, discover themes from source files in
`web_src/css/themes/` instead of AssetFS. In prod, use AssetFS only.
Extract shared `collectThemeFiles` helper to deduplicate theme file
handling.
2. Implement `fs.ReadDirFS` on `LayeredFS` to support theme file
discovery.
3. `IsViteDevMode` now performs an HTTP health check against the vite
dev server instead of only checking the port file exists. Result is
cached with a 1-second TTL.
4. Refactor theme caching from mutex to atomic pointer with time-based
invalidation, allowing themes to refresh when vite dev mode state
changes.
5. Move `ViteDevMiddleware` into `ProtocolMiddlewares` so it applies to
both install and web routes.
6. Show a `ViteDevMode` label in the page footer when vite dev server is
active.
7. Add `/__vite_dev_server_check` endpoint to vite dev server for the
health check.
8. Ensure `.vite` directory exists before writing the dev-port file.
9. Minor CSS fixes: footer gap, navbar mobile alignment.

---
This PR was written with the help of Claude Opus 4.6

---------

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-03-30 14:59:10 +00:00

336 lines
12 KiB
TypeScript

import {build, defineConfig} from 'vite';
import vuePlugin from '@vitejs/plugin-vue';
import {stringPlugin} from 'vite-string-plugin';
import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync} from 'node:fs';
import path, {join, parse} from 'node:path';
import {env} from 'node:process';
import tailwindcss from 'tailwindcss';
import tailwindConfig from './tailwind.config.ts';
import wrapAnsi from 'wrap-ansi';
import licensePlugin from 'rollup-plugin-license';
import type {InlineConfig, Plugin, Rolldown} from 'vite';
const isProduction = env.NODE_ENV !== 'development';
// ENABLE_SOURCEMAP accepts the following values:
// true - all sourcemaps enabled, the default in development
// reduced - sourcemaps only for index.js, the default in production
// false - all sourcemaps disabled
let enableSourcemap: string;
if ('ENABLE_SOURCEMAP' in env) {
enableSourcemap = ['true', 'false'].includes(env.ENABLE_SOURCEMAP!) ? env.ENABLE_SOURCEMAP! : 'reduced';
} else {
enableSourcemap = isProduction ? 'reduced' : 'true';
}
const outDir = join(import.meta.dirname, 'public/assets');
const themes: Record<string, string> = {};
for (const path of globSync('web_src/css/themes/*.css', {cwd: import.meta.dirname})) {
themes[parse(path).name] = join(import.meta.dirname, path);
}
const webComponents = new Set([
// our own, in web_src/js/webcomponents
'overflow-menu',
'origin-url',
'relative-time',
// from dependencies
'markdown-toolbar',
'text-expander',
]);
function formatLicenseText(licenseText: string) {
return wrapAnsi(licenseText || '', 80).trim();
}
const commonRolldownOptions: Rolldown.RolldownOptions = {
checks: {
eval: false, // htmx needs eval
pluginTimings: false,
},
};
function commonViteOpts({build, ...other}: InlineConfig): InlineConfig {
const {rolldownOptions, ...otherBuild} = build || {};
return {
base: './', // make all asset URLs relative, so it works in subdirectory deployments
configFile: false,
root: import.meta.dirname,
publicDir: false,
build: {
outDir,
emptyOutDir: false,
sourcemap: enableSourcemap !== 'false',
target: 'es2020',
minify: isProduction ? 'oxc' : false,
cssMinify: isProduction ? 'esbuild' : false,
chunkSizeWarningLimit: Infinity,
assetsInlineLimit: 32768,
reportCompressedSize: false,
rolldownOptions: {
...commonRolldownOptions,
...rolldownOptions,
},
...otherBuild,
},
...other,
};
}
const iifeEntry = join(import.meta.dirname, 'web_src/js/iife.ts');
function iifeBuildOpts({entryFileNames, write}: {entryFileNames: string, write?: boolean}) {
return commonViteOpts({
build: {
lib: {entry: iifeEntry, formats: ['iife'], name: 'iife'},
rolldownOptions: {output: {entryFileNames}},
...(write === false && {write: false}),
},
plugins: [stringPlugin()],
});
}
// Build iife.js as a blocking IIFE bundle. In dev mode, serves it from memory
// and rebuilds on file changes. In prod mode, writes to disk during closeBundle.
function iifePlugin(): Plugin {
let iifeCode = '';
let iifeMap = '';
const iifeModules = new Set<string>();
let isBuilding = false;
return {
name: 'iife',
async configureServer(server) {
const buildAndCache = async () => {
const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.js', write: false}));
const output = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput;
const chunk = output.output[0];
iifeCode = chunk.code.replace(/\/\/# sourceMappingURL=.*/, '//# sourceMappingURL=__vite_iife.js.map');
const mapAsset = output.output.find((o) => o.fileName.endsWith('.map'));
iifeMap = mapAsset && 'source' in mapAsset ? String(mapAsset.source) : '';
iifeModules.clear();
for (const id of Object.keys(chunk.modules)) iifeModules.add(id);
};
await buildAndCache();
let needsRebuild = false;
server.watcher.on('change', async (path) => {
if (!iifeModules.has(path)) return;
needsRebuild = true;
if (isBuilding) return;
isBuilding = true;
try {
do {
needsRebuild = false;
await buildAndCache();
} while (needsRebuild);
server.ws.send({type: 'full-reload'});
} finally {
isBuilding = false;
}
});
server.middlewares.use((req, res, next) => {
// "__vite_iife" is a virtual file in memory, serve it directly
const pathname = req.url!.split('?')[0];
if (pathname === '/web_src/js/__vite_dev_server_check') {
res.end('ok');
} else if (pathname === '/web_src/js/__vite_iife.js') {
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Cache-Control', 'no-store');
res.end(iifeCode);
} else if (pathname === '/web_src/js/__vite_iife.js.map') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(iifeMap);
} else {
next();
}
});
},
async closeBundle() {
for (const file of globSync('js/iife.*.js*', {cwd: outDir})) unlinkSync(join(outDir, file));
const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.[hash:8].js'}));
const buildOutput = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput;
const entry = buildOutput.output.find((o) => o.fileName.startsWith('js/iife.'));
if (!entry) throw new Error('IIFE build produced no output');
const manifestPath = join(outDir, '.vite', 'manifest.json');
writeFileSync(manifestPath, JSON.stringify({
...JSON.parse(readFileSync(manifestPath, 'utf8')),
'web_src/js/iife.ts': {file: entry.fileName, name: 'iife', isEntry: true},
}, null, 2));
},
};
}
// In reduced sourcemap mode, only keep sourcemaps for main files
function reducedSourcemapPlugin(): Plugin {
return {
name: 'reduced-sourcemap',
apply: 'build',
closeBundle() {
if (enableSourcemap !== 'reduced') return;
for (const file of globSync('{js,css}/*.map', {cwd: outDir})) {
if (!file.startsWith('js/index.') && !file.startsWith('js/iife.')) unlinkSync(join(outDir, file));
}
},
};
}
// Filter out legacy font formats from CSS, keeping only woff2
function filterCssUrlPlugin(): Plugin {
return {
name: 'filter-css-url',
enforce: 'pre',
transform(code, id) {
if (!id.endsWith('.css') || !id.includes('katex')) return null;
return code.replace(/,\s*url\([^)]*\.(?:woff|ttf)\)\s*format\("[^"]*"\)/gi, '');
},
};
}
const viteDevServerPort = Number(env.FRONTEND_DEV_SERVER_PORT) || 3001;
const viteDevPortFilePath = join(outDir, '.vite', 'dev-port');
// Write the Vite dev server's actual port to a file so the Go server can discover it for proxying.
function viteDevServerPortPlugin(): Plugin {
return {
name: 'vite-dev-server-port',
apply: 'serve',
configureServer(server) {
server.httpServer!.once('listening', () => {
const addr = server.httpServer!.address();
if (typeof addr === 'object' && addr) {
mkdirSync(path.dirname(viteDevPortFilePath), {recursive: true});
writeFileSync(viteDevPortFilePath, String(addr.port));
}
});
},
};
}
export default defineConfig(commonViteOpts({
appType: 'custom', // Go serves all HTML, disable Vite's HTML handling
clearScreen: false,
server: {
port: viteDevServerPort,
open: false,
host: '0.0.0.0',
strictPort: false,
fs: {
// VITE-DEV-SERVER-SECURITY: the dev server will be exposed to public by Gitea's web server, so we need to strictly limit the access
// Otherwise `/@fs/*` will be able to access any file (including app.ini which contains INTERNAL_TOKEN)
strict: true,
allow: [
'assets',
'node_modules',
'public',
'web_src',
// do not add any other directories here, unless you are absolutely sure it's safe to expose them to the public
],
},
headers: {
'Cache-Control': 'no-store', // prevent browser disk cache
},
warmup: {
clientFiles: [
// warmup the important entry points
'web_src/js/index.ts',
'web_src/css/index.css',
'web_src/css/themes/*.css',
],
},
},
build: {
modulePreload: false,
manifest: true,
rolldownOptions: {
input: {
index: join(import.meta.dirname, 'web_src/js/index.ts'),
swagger: join(import.meta.dirname, 'web_src/js/standalone/swagger.ts'),
'external-render-iframe': join(import.meta.dirname, 'web_src/js/standalone/external-render-iframe.ts'),
'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/features/eventsource.sharedworker.ts'),
...(!isProduction && {
devtest: join(import.meta.dirname, 'web_src/js/standalone/devtest.ts'),
}),
...themes,
},
output: {
entryFileNames: 'js/[name].[hash:8].js',
chunkFileNames: 'js/[name].[hash:8].js',
assetFileNames: ({names}) => {
const name = names[0];
if (name.endsWith('.css')) return 'css/[name].[hash:8].css';
if (/\.(ttf|woff2?)$/.test(name)) return 'fonts/[name].[hash:8].[ext]';
return '[name].[hash:8].[ext]';
},
},
},
},
worker: {
rolldownOptions: {
...commonRolldownOptions,
output: {
entryFileNames: 'js/[name].[hash:8].js',
},
},
},
css: {
transformer: 'postcss',
postcss: {
plugins: [
tailwindcss(tailwindConfig),
],
},
},
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
},
plugins: [
iifePlugin(),
viteDevServerPortPlugin(),
reducedSourcemapPlugin(),
filterCssUrlPlugin(),
stringPlugin(),
vuePlugin({
template: {
compilerOptions: {
isCustomElement: (tag) => webComponents.has(tag),
},
},
}),
isProduction ? licensePlugin({
thirdParty: {
output: {
file: join(import.meta.dirname, 'public/assets/licenses.txt'),
template(deps) {
const line = '-'.repeat(80);
const goJson = readFileSync(join(import.meta.dirname, 'assets/go-licenses.json'), 'utf8');
const goModules = JSON.parse(goJson).map(({name, licenseText}: {name: string, licenseText: string}) => {
return {name, body: formatLicenseText(licenseText)};
});
const jsModules = deps.map((dep) => {
return {name: dep.name, version: dep.version, body: formatLicenseText(dep.licenseText ?? '')};
});
const modules = [...goModules, ...jsModules].sort((a, b) => a.name.localeCompare(b.name));
return modules.map(({name, version, body}: {name: string, version?: string, body: string}) => {
const title = version ? `${name}@${version}` : name;
return `${line}\n${title}\n${line}\n${body}`;
}).join('\n');
},
},
allow(dependency) {
if (dependency.name === 'khroma') return true; // MIT: https://github.com/fabiospampinato/khroma/pull/33
return /(Apache-2\.0|0BSD|BSD-2-Clause|BSD-3-Clause|MIT|ISC|CPAL-1\.0|Unlicense|EPL-1\.0|EPL-2\.0)/.test(dependency.license ?? '');
},
},
}) : {
name: 'dev-licenses-stub',
closeBundle() {
writeFileSync(join(outDir, 'licenses.txt'), 'Licenses are disabled during development');
},
},
],
}));