mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-01 00:16:25 +02:00
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>
336 lines
12 KiB
TypeScript
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');
|
|
},
|
|
},
|
|
],
|
|
}));
|