feat: esm

This commit is contained in:
winixt 2025-09-05 22:40:22 +08:00
parent effd1378b4
commit c4c081ae3a
529 changed files with 18480 additions and 17638 deletions

1
.npmrc
View File

@ -1,3 +1,2 @@
registry=https://registry.npmmirror.com registry=https://registry.npmmirror.com
auto-install-peers=true
shamefully-hoist=true shamefully-hoist=true

View File

@ -2,7 +2,6 @@
import antfu from '@antfu/eslint-config'; import antfu from '@antfu/eslint-config';
export default await antfu({ export default await antfu({
// TODO: 使用 ignore 代替 cli 命令中的配置
stylistic: { stylistic: {
indent: 4, indent: 4,
quotes: 'single', quotes: 'single',

View File

@ -3,7 +3,7 @@
"type": "module", "type": "module",
"version": "3.4.12", "version": "3.4.12",
"private": true, "private": true,
"packageManager": "pnpm@8.6.6", "packageManager": "pnpm@10.14.0",
"description": "一个好用的前端管理台快速开发框架", "description": "一个好用的前端管理台快速开发框架",
"preferGlobal": true, "preferGlobal": true,
"workspaces": [ "workspaces": [
@ -37,23 +37,24 @@
"enquirer": "^2.3.6", "enquirer": "^2.3.6",
"execa": "^6.1.0", "execa": "^6.1.0",
"minimist": "^1.2.6", "minimist": "^1.2.6",
"semver": "^7.3.6" "semver": "^7.3.6",
"tsup": "^8.5.0"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^3.8.0", "@antfu/eslint-config": "^5.2.2",
"@commitlint/cli": "^18.4.4", "@commitlint/cli": "^18.4.4",
"@commitlint/config-conventional": "^18.4.4", "@commitlint/config-conventional": "^18.4.4",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"commitizen": "^4.3.1", "commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0", "cz-conventional-changelog": "^3.3.0",
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"eslint": "^9.13.0", "eslint": "^9.34.0",
"fs-extra": "^11.1.1", "fs-extra": "^11.3.1",
"lint-staged": "^15.2.0", "lint-staged": "^15.2.0",
"simple-git-hooks": "^2.9.0", "simple-git-hooks": "^2.9.0",
"typescript": "^5.6.3", "typescript": "^5.9.2",
"vitepress": "1.0.0-alpha.73", "vitepress": "1.0.0-alpha.73",
"vue": "^3.3.4", "vue": "^3.5.21",
"yargs-parser": "^21.1.1" "yargs-parser": "^21.1.1"
}, },
"simple-git-hooks": { "simple-git-hooks": {

View File

@ -8,7 +8,7 @@
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/WeBankFinTech/fes.js.git", "url": "git+https://github.com/WeBankFinTech/fes.js.git",
"directory": "packages/fes-builder-vite" "directory": "packages/builder-vite"
}, },
"bugs": { "bugs": {
"url": "https://github.com/WeBankFinTech/fes.js/issues" "url": "https://github.com/WeBankFinTech/fes.js/issues"
@ -16,44 +16,49 @@
"keywords": [ "keywords": [
"fes" "fes"
], ],
"main": "lib/index.js", "main": "dist/index.mjs",
"module": "dist/index.mjs",
"files": [ "files": [
"lib", "dist",
"types.d.ts" "types.d.ts"
], ],
"scripts": {
"dev": "tsup --watch --sourcemap",
"build": "tsup"
},
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"peerDependencies": { "peerDependencies": {
"@fesjs/fes": "^3.1.12", "@fesjs/fes": "^3.1.12",
"core-js": "^3.29.1" "core-js": "^3.45.1"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.23.3", "@fesjs/shared": "workspace:*",
"@fesjs/utils": "^3.0.3", "@fesjs/utils": "workspace:*",
"@rollup/pluginutils": "^5.1.0", "@rollup/pluginutils": "^5.1.0",
"@vitejs/plugin-basic-ssl": "^1.0.2", "@vitejs/plugin-basic-ssl": "^2.1.0",
"@vitejs/plugin-legacy": "^5.2.0", "@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-vue": "^4.5.0", "@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^3.1.0", "@vitejs/plugin-vue-jsx": "^5.1.1",
"autoprefixer": "^10.4.4", "autoprefixer": "^10.4.21",
"colorette": "^2.0.16", "colorette": "^2.0.16",
"connect-history-api-fallback": "^2.0.0", "connect-history-api-fallback": "^2.0.0",
"consola": "^2.15.3", "consola": "^3.4.2",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"dotenv-expand": "^8.0.2", "dotenv-expand": "^8.0.2",
"ejs": "^3.1.6", "ejs": "^3.1.6",
"fast-glob": "^3.2.11", "fast-glob": "^3.2.11",
"fs-extra": "^10.0.1", "fs-extra": "^11.3.1",
"html-minifier-terser": "^6.1.0", "html-minifier-terser": "^6.1.0",
"less": "^4.2.0", "less": "^4.2.0",
"node-html-parser": "^5.3.3", "node-html-parser": "^5.3.3",
"pathe": "^0.2.0", "pathe": "^0.2.0",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-safe-parser": "^6.0.0", "postcss-safe-parser": "^6.0.0",
"rollup-plugin-visualizer": "^5.9.3", "rollup-plugin-visualizer": "^6.0.3",
"terser": "^5.24.0", "terser": "^5.24.0",
"vite": "^5.0.3" "vite": "^7.1.4"
}, },
"typings": "./types.d.ts" "typings": "./types.d.ts"
} }

View File

@ -1,9 +1,14 @@
import { splitVendorChunkPlugin } from 'vite'; import type { IPluginAPI } from '@fesjs/shared';
import type { InlineConfig, UserConfig } from 'vite';
import type { ViteBuildConfig } from '../../shared';
import legacy from '@vitejs/plugin-legacy'; import legacy from '@vitejs/plugin-legacy';
import autoprefixer from 'autoprefixer';
import postcssFlexbugsFixes from 'postcss-flexbugs-fixes';
import postcssSafeParser from 'postcss-safe-parser';
import { getInnerCommonConfig } from '../../common/getConfig'; import { getInnerCommonConfig } from '../../common/getConfig';
function getEsbuildTarget(targets) { function getEsbuildTarget(targets: any): string[] {
const result = []; const result: string[] = [];
['chrome', 'edge', 'firefox', 'hermes', 'ios', 'node', 'opera', 'rhino', 'safari'].forEach((key) => { ['chrome', 'edge', 'firefox', 'hermes', 'ios', 'node', 'opera', 'rhino', 'safari'].forEach((key) => {
if (targets[key]) { if (targets[key]) {
result.push(`${key}${targets[key]}`); result.push(`${key}${targets[key]}`);
@ -12,20 +17,20 @@ function getEsbuildTarget(targets) {
return result; return result;
} }
export default async (api) => { export default async (api: IPluginAPI<ViteBuildConfig>): Promise<InlineConfig> => {
const { deepmerge, getTargetsAndBrowsersList } = api.utils; const { deepmerge, getTargetsAndBrowsersList } = api.utils;
const { build = {} } = api.config.viteOption; const { build = {} } = api.config.vite || api.config.viteOption as UserConfig;
const { targets, browserslist } = getTargetsAndBrowsersList({ config: api.config }); const { targets, browserslist } = getTargetsAndBrowsersList({ config: api.config });
const bundleConfig = deepmerge(getInnerCommonConfig(api), { const bundleConfig: InlineConfig = deepmerge(getInnerCommonConfig(api), {
mode: 'production', mode: 'production',
css: { css: {
postcss: { postcss: {
plugins: [ plugins: [
require('postcss-flexbugs-fixes'), postcssFlexbugsFixes,
require('postcss-safe-parser'), postcssSafeParser,
require('autoprefixer')({ autoprefixer({
...api.config.autoprefixer, ...api.config.autoprefixer,
overrideBrowserslist: browserslist, overrideBrowserslist: browserslist,
}), }),
@ -39,12 +44,10 @@ export default async (api) => {
targets, targets,
...api.config.viteLegacy, ...api.config.viteLegacy,
}), }),
splitVendorChunkPlugin(),
], ],
build: { build: {
...build, ...build,
terserOptions: build.terserOptions || api.config.terserOptions, terserOptions: build.terserOptions || api.config.terserOptions,
target: build.target || getEsbuildTarget(targets),
outDir: build.outDir || api.config.outputPath || 'dist', outDir: build.outDir || api.config.outputPath || 'dist',
assetsDir: build.assetsDir || 'static', assetsDir: build.assetsDir || 'static',
assetsInlineLimit: build.assetsInlineLimit || api.config.inlineLimit || 8192, assetsInlineLimit: build.assetsInlineLimit || api.config.inlineLimit || 8192,

View File

@ -0,0 +1,40 @@
import type { IPluginAPI } from '@fesjs/shared';
import type { InlineConfig } from 'vite';
import { existsSync } from 'node:fs';
import process from 'node:process';
import { build } from 'vite';
import getBuildConfig from './getBuildConfig';
export default function (api: IPluginAPI) {
const {
paths,
utils: { rimraf },
} = api;
api.registerCommand({
command: 'build',
description: 'build application for production',
async fn() {
rimraf.sync(paths.absTmpPath);
// generate files
await api.applyPlugins({
key: 'onGenerateFiles',
type: api.ApplyPluginsType.event,
});
const bundleConfig: InlineConfig = await getBuildConfig(api);
// clear output path before exec build
if (process.env.CLEAR_OUTPUT !== 'none') {
if (paths.absOutputPath && existsSync(paths.absOutputPath)) {
rimraf.sync(paths.absOutputPath);
}
}
await build(bundleConfig);
if (process.env.RM_TMPDIR !== 'none') {
rimraf.sync(paths.absTmpPath);
}
},
});
}

View File

@ -1,16 +1,27 @@
import type { IPluginAPI } from '@fesjs/shared';
import type { InlineConfig } from 'vite';
import process from 'node:process';
import basicSsl from '@vitejs/plugin-basic-ssl'; import basicSsl from '@vitejs/plugin-basic-ssl';
import { getInnerCommonConfig } from '../../common/getConfig'; import { getInnerCommonConfig } from '../../common/getConfig';
import viteMiddlewarePlugin from './viteMiddlewarePlugin'; import viteMiddlewarePlugin from './viteMiddlewarePlugin';
export default async (api, args) => { interface Args {
port?: string | number;
https?: boolean;
[key: string]: any;
}
export default async (api: IPluginAPI, args: Args): Promise<InlineConfig> => {
const { deepmerge, getPort, changePort, getHostName } = api.utils; const { deepmerge, getPort, changePort, getHostName } = api.utils;
const port = await getPort(process.env.PORT || args.port || api.config.viteOption.server?.port); const viteOption = api.config.vite || api.config.viteOption || {};
const port = await getPort(process.env.PORT || args.port || viteOption.server?.port);
changePort(port); changePort(port);
const hostname = getHostName(api.config.viteOption.server?.host); const hostname = getHostName(viteOption.server?.host);
const { server } = api.config.viteOption; const { server } = viteOption;
const beforeMiddlewares = await api.applyPlugins({ const beforeMiddlewares = await api.applyPlugins({
key: 'addBeforeMiddlewares', key: 'addBeforeMiddlewares',
@ -25,9 +36,9 @@ export default async (api, args) => {
args: {}, args: {},
}); });
const isHTTPS = !!(process.env.HTTPS || args.https || api.config.viteOption.server?.https); const isHTTPS = !!(process.env.HTTPS || args.https || viteOption.server?.https);
const bundleConfig = deepmerge(getInnerCommonConfig(api), { const bundleConfig: InlineConfig = deepmerge(getInnerCommonConfig(api), {
mode: 'development', mode: 'development',
plugins: [viteMiddlewarePlugin(beforeMiddlewares, middlewares), isHTTPS && basicSsl()].filter(Boolean), plugins: [viteMiddlewarePlugin(beforeMiddlewares, middlewares), isHTTPS && basicSsl()].filter(Boolean),
server: { server: {

View File

@ -1,16 +1,28 @@
import type { IPluginAPI } from '@fesjs/shared';
import type { ViteDevServer } from 'vite';
import process from 'node:process';
import { createServer } from 'vite'; import { createServer } from 'vite';
import getDevConfig from './getDevConfig'; import getDevConfig from './getDevConfig';
export default (api) => { interface Args {
args?: Record<string, any>;
rawArgv?: Record<string, any>;
options?: Record<string, any>;
program?: any;
}
export default (api: IPluginAPI) => {
const { const {
paths, paths,
utils: { chalk, rimraf }, utils: { chalk, rimraf },
} = api; } = api;
let server; let server: ViteDevServer | undefined;
function destroy() { function destroy() {
server?.close(); if (server) {
server.close().catch(() => {});
}
} }
api.registerCommand({ api.registerCommand({
@ -26,7 +38,7 @@ export default (api) => {
description: 'whether to turn on the https service', description: 'whether to turn on the https service',
}, },
], ],
async fn({ args = {} }) { async fn({ args = {} }: Args) {
rimraf.sync(paths.absTmpPath); rimraf.sync(paths.absTmpPath);
await api.applyPlugins({ await api.applyPlugins({
@ -50,11 +62,14 @@ export default (api) => {
api.registerMethod({ api.registerMethod({
name: 'restartServer', name: 'restartServer',
fn() { fn() {
// eslint-disable-next-line no-console
console.log(chalk.gray('Try to restart dev server...')); console.log(chalk.gray('Try to restart dev server...'));
destroy(); destroy();
process.send({ if (typeof process !== 'undefined' && process.send) {
type: 'RESTART', process.send({
}); type: 'RESTART',
});
}
}, },
}); });
}; };

View File

@ -1,6 +1,12 @@
export default (beforeMiddlewares = [], afterMiddlewares = []) => ({ import type { Plugin } from 'vite';
interface Middleware {
(req: any, res: any, next: any): void;
}
export default (beforeMiddlewares: Middleware[] = [], afterMiddlewares: Middleware[] = []): Plugin => ({
name: 'server-middleware-plugin', name: 'server-middleware-plugin',
configureServer(server) { configureServer(server: any) {
beforeMiddlewares.forEach((middleware) => { beforeMiddlewares.forEach((middleware) => {
server.middlewares.use(middleware); server.middlewares.use(middleware);
}); });

View File

@ -1,8 +1,10 @@
import type { Plugin } from 'vite';
export default { export default {
name: 'sfc-config', name: 'sfc-config',
transform(code, id) { transform(code: string, id: string) {
if (/vue&type=config/.test(id)) { if (/vue&type=config/.test(id)) {
return `export default ''`; return `export default ''`;
} }
}, },
}; } as Plugin;

View File

@ -0,0 +1,26 @@
import type { Connect } from 'vite';
import { join } from 'node:path';
import historyFallback from 'connect-history-api-fallback';
import { pathExistsSync } from 'fs-extra/esm';
interface ViteConfig {
publicDir: string;
}
interface HistoryParams {
[key: string]: any;
}
function proxyMiddleware(viteConfig: ViteConfig, params: HistoryParams): Connect.NextHandleFunction {
return (req: any, res: any, next: any) => {
const fileName = join(viteConfig.publicDir, req.url);
if (req.url.length > 1 && req.url.startsWith('/') && pathExistsSync(fileName)) {
return next();
}
const history = historyFallback(params);
history(req, res, next);
};
}
export default proxyMiddleware;

View File

@ -1,23 +1,15 @@
import type { IPluginAPI } from '@fesjs/shared';
import type { InlineConfig } from 'vite';
import { join } from 'node:path'; import { join } from 'node:path';
import { existsSync } from 'node:fs';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx'; import vueJsx from '@vitejs/plugin-vue-jsx';
import { createHtmlPlugin } from './vite-plugin-html';
import SFCConfigBlockPlugin from './SFCConfigBlockPlugin';
import getDefine from './getDefine'; import getDefine from './getDefine';
import SFCConfigBlockPlugin from './SFCConfigBlockPlugin';
import { createHtmlPlugin } from './vite-plugin-html';
function getPostcssConfig(api) { export function getInnerCommonConfig(api: IPluginAPI): InlineConfig {
// TODO 支持其他 postcss 配置文件类型
const configPath = `${api.paths.cwd}/postcss.config.js`;
if (existsSync(configPath))
return require(`${api.paths.cwd}/postcss.config.js`);
return {};
}
export function getInnerCommonConfig(api) {
const { deepmerge, resolveRuntimeEnv } = api.utils; const { deepmerge, resolveRuntimeEnv } = api.utils;
const { base, ...otherViteOption } = api.config.viteOption; const { base, ...otherViteOption } = (api.config.vite || api.config.viteOption);
const publicPath = base || api.config.publicPath || '/'; const publicPath = base || api.config.publicPath || '/';
@ -27,11 +19,6 @@ export function getInnerCommonConfig(api) {
configFile: false, configFile: false,
define: getDefine(api, publicPath), define: getDefine(api, publicPath),
cacheDir: join(api.cwd, '.cache'), cacheDir: join(api.cwd, '.cache'),
css: {
postcss: {
...getPostcssConfig(api),
},
},
plugins: [ plugins: [
vue(api.config.viteVuePlugin || {}), vue(api.config.viteVuePlugin || {}),
SFCConfigBlockPlugin, SFCConfigBlockPlugin,

View File

@ -1,17 +1,19 @@
import type { IPluginAPI } from '@fesjs/shared';
import type { ViteBuildConfig } from '../shared';
import { resolveRuntimeEnv, stringifyObjValue } from '@fesjs/utils'; import { resolveRuntimeEnv, stringifyObjValue } from '@fesjs/utils';
export default (api, publicPath) => { export default (api: IPluginAPI<ViteBuildConfig>, publicPath: string): Record<string, any> => {
const viteOption = api.config.viteOption; const viteOption = api.config.vite || api.config.viteOption;
const env = resolveRuntimeEnv(publicPath); const env = resolveRuntimeEnv(publicPath);
const define = stringifyObjValue({ const define = stringifyObjValue({
...api.config.define, ...api.config.define,
...viteOption.define, ...(viteOption ? viteOption.define : {}),
}); });
const formatEnv = Object.keys(env).reduce((acc, cur) => { const formatEnv = Object.keys(env).reduce((acc, cur) => {
acc[`process.env.${cur}`] = JSON.stringify(env[cur]); acc[`process.env.${cur}`] = JSON.stringify(env[cur]);
return acc; return acc;
}, {}); }, {} as Record<string, string>);
return { return {
...formatEnv, ...formatEnv,

View File

@ -1,34 +1,52 @@
import type { ConfigEnv, Plugin, ResolvedConfig, ViteDevServer } from 'vite';
import process from 'node:process'; import process from 'node:process';
import { render } from 'ejs';
import { expand } from 'dotenv-expand';
import dotenv from 'dotenv';
import path, { dirname, join } from 'pathe';
import fse from 'fs-extra';
import { normalizePath } from 'vite';
import { parse } from 'node-html-parser';
import fg from 'fast-glob';
import consola from 'consola';
import { dim } from 'colorette';
import { minify } from 'html-minifier-terser';
import { createFilter } from '@rollup/pluginutils'; import { createFilter } from '@rollup/pluginutils';
import { dim } from 'colorette';
import consola from 'consola';
import dotenv from 'dotenv';
import { expand } from 'dotenv-expand';
import { render } from 'ejs';
import fg from 'fast-glob';
import fse from 'fs-extra';
import { minify } from 'html-minifier-terser';
import { parse } from 'node-html-parser';
import path, { dirname, join } from 'pathe';
import { normalizePath } from 'vite';
import history from './connectHistoryMiddleware'; import history from './connectHistoryMiddleware';
function lookupFile(dir, formats, pathOnly = false) { interface Env {
for (const format of formats) { [key: string]: string;
const fullPath = join(dir, format);
if (fse.pathExistsSync(fullPath) && fse.statSync(fullPath).isFile())
return pathOnly ? fullPath : fse.readFileSync(fullPath, 'utf-8');
}
const parentDir = dirname(dir);
if (parentDir !== dir)
return lookupFile(parentDir, formats, pathOnly);
} }
function loadEnv(mode, envDir, prefix = '') { interface ParsedUrl {
if (mode === 'local') pathname: string;
throw new Error(`"local" cannot be used as a mode name because it conflicts with the .local postfix for .env files.`); path: string;
}
const env = {}; interface Rewrites {
from: RegExp;
to: (params: { parsedUrl: ParsedUrl }) => string;
}
function lookupFile(dir: string, formats: string[], pathOnly = false): string | undefined {
for (const format of formats) {
const fullPath = join(dir, format);
if (fse.pathExistsSync(fullPath) && fse.statSync(fullPath).isFile()) {
return pathOnly ? fullPath : fse.readFileSync(fullPath, 'utf-8');
}
}
const parentDir = dirname(dir);
if (parentDir !== dir) {
return lookupFile(parentDir, formats, pathOnly);
}
}
function loadEnv(mode: string, envDir: string, prefix = ''): Env {
if (mode === 'local') {
throw new Error(`"local" cannot be used as a mode name because it conflicts with the .local postfix for .env files.`);
}
const env: Env = {};
const envFiles = [`.env.${mode}.local`, `.env.${mode}`, `.env.local`, `.env`]; const envFiles = [`.env.${mode}.local`, `.env.${mode}`, `.env.local`, `.env`];
for (const file of envFiles) { for (const file of envFiles) {
const _path = lookupFile(envDir, [file], true); const _path = lookupFile(envDir, [file], true);
@ -39,18 +57,20 @@ function loadEnv(mode, envDir, prefix = '') {
ignoreProcessEnv: true, ignoreProcessEnv: true,
}); });
for (const [key, value] of Object.entries(parsed)) { for (const [key, value] of Object.entries(parsed)) {
if (key.startsWith(prefix) && env[key] === undefined) if (key.startsWith(prefix) && env[key] === undefined) {
env[key] = value; env[key] = value;
}
else if (key === 'NODE_ENV') else if (key === 'NODE_ENV') {
process.env.VITE_USER_NODE_ENV = value; process.env.VITE_USER_NODE_ENV = value;
}
} }
} }
} }
return env; return env;
} }
async function isDirEmpty(dir) { async function isDirEmpty(dir: string): Promise<boolean> {
return fse.readdir(dir).then(files => files.length === 0); return fse.readdir(dir).then(files => files.length === 0);
} }
@ -58,19 +78,44 @@ const DEFAULT_TEMPLATE = 'index.html';
const ignoreDirs = ['.', '', '/']; const ignoreDirs = ['.', '', '/'];
const bodyInjectRE = /<\/body>/; const bodyInjectRE = /<\/body>/;
function createPlugin(userOptions = {}) { interface UserOptions {
entry?: string;
template?: string;
pages?: Page[];
verbose?: boolean;
injectOptions?: InjectOptions;
_minify?: boolean;
[key: string]: any;
}
interface Page {
filename?: string;
template?: string;
entry?: string;
injectOptions?: InjectOptions;
}
interface InjectOptions {
data?: Record<string, any>;
ejsOptions?: any;
tags?: any[];
}
function createPlugin(userOptions: UserOptions = {}): Plugin {
const { entry, template = DEFAULT_TEMPLATE, pages = [], verbose = false } = userOptions; const { entry, template = DEFAULT_TEMPLATE, pages = [], verbose = false } = userOptions;
let viteConfig; let viteConfig: ResolvedConfig;
let env = {}; let env: Env = {};
return { return {
name: 'vite:html', name: 'vite:html',
order: 'pre', enforce: 'pre',
configResolved(resolvedConfig) { configResolved(resolvedConfig: ResolvedConfig) {
viteConfig = resolvedConfig; viteConfig = resolvedConfig!;
env = loadEnv(viteConfig.mode, viteConfig.root, ''); if (viteConfig.mode && viteConfig.root) {
env = loadEnv(viteConfig.mode, viteConfig.root, '');
}
}, },
config(conf) { config(config: any, env: ConfigEnv) {
const input = createInput(userOptions, conf); const input = createInput(userOptions, env);
if (input) { if (input) {
return { return {
build: { build: {
@ -81,9 +126,9 @@ function createPlugin(userOptions = {}) {
}; };
} }
}, },
configureServer(server) { configureServer(server: ViteDevServer) {
let _pages = []; const _pages: Page[] = [];
const rewrites = []; const rewrites: Rewrites[] = [];
if (!isMpa(viteConfig)) { if (!isMpa(viteConfig)) {
const template2 = userOptions.template || DEFAULT_TEMPLATE; const template2 = userOptions.template || DEFAULT_TEMPLATE;
const filename = DEFAULT_TEMPLATE; const filename = DEFAULT_TEMPLATE;
@ -93,24 +138,25 @@ function createPlugin(userOptions = {}) {
}); });
} }
else { else {
_pages = pages.map(page => ({ _pages.push(...pages.map(page => ({
filename: page.filename || DEFAULT_TEMPLATE, filename: page.filename || DEFAULT_TEMPLATE,
template: page.template || DEFAULT_TEMPLATE, template: page.template || DEFAULT_TEMPLATE,
})); })));
} }
const proxy = viteConfig.server?.proxy ?? {}; const proxy = viteConfig.server?.proxy ?? {};
const baseUrl = viteConfig.base ?? '/'; const baseUrl = viteConfig.base ?? '/';
const keys = Object.keys(proxy); const keys = Object.keys(proxy);
let indexPage = null; let indexPage: Page | null = null;
for (const page of _pages) { for (const page of _pages) {
if (page.filename !== 'index.html') if (page.filename !== 'index.html') {
rewrites.push(createRewire(page.template, page, baseUrl, keys)); rewrites.push(createRewire(page.template || '', page, baseUrl, keys));
}
else else { indexPage = page; }
indexPage = page;
} }
if (indexPage) if (indexPage) {
rewrites.push(createRewire('', indexPage, baseUrl, keys)); rewrites.push(createRewire('', indexPage, baseUrl, keys));
}
server.middlewares.use( server.middlewares.use(
history(viteConfig, { history(viteConfig, {
@ -122,7 +168,7 @@ function createPlugin(userOptions = {}) {
}, },
transformIndexHtml: { transformIndexHtml: {
order: 'pre', order: 'pre',
async handler(html, ctx) { handler: async (html: string, ctx: any) => {
const url = ctx.filename; const url = ctx.filename;
const base = viteConfig.base; const base = viteConfig.base;
const excludeBaseUrl = url.replace(base, '/'); const excludeBaseUrl = url.replace(base, '/');
@ -144,18 +190,22 @@ function createPlugin(userOptions = {}) {
}, },
}, },
async closeBundle() { async closeBundle() {
const outputDirs = []; const outputDirs: string[] = [];
if (isMpa(viteConfig) || pages.length) { if (isMpa(viteConfig) || pages.length) {
for (const page of pages) { for (const page of pages) {
const dir = path.dirname(page.template); if (page.template) {
if (!ignoreDirs.includes(dir)) const dir = path.dirname(page.template);
outputDirs.push(dir); if (!ignoreDirs.includes(dir)) {
outputDirs.push(dir);
}
}
} }
} }
else { else {
const dir = path.dirname(template); const dir = path.dirname(template);
if (!ignoreDirs.includes(dir)) if (!ignoreDirs.includes(dir)) {
outputDirs.push(dir); outputDirs.push(dir);
}
} }
const cwd = path.resolve(viteConfig.root, viteConfig.build.outDir); const cwd = path.resolve(viteConfig.root, viteConfig.build.outDir);
const htmlFiles = await fg( const htmlFiles = await fg(
@ -176,41 +226,45 @@ function createPlugin(userOptions = {}) {
await Promise.all( await Promise.all(
htmlDirs.map(async (item) => { htmlDirs.map(async (item) => {
const isEmpty = await isDirEmpty(item); const isEmpty = await isDirEmpty(item);
if (isEmpty) if (isEmpty) {
return fse.remove(item); return fse.remove(item);
}
}), }),
); );
}, },
}; };
} }
function createInput({ pages = [], template = DEFAULT_TEMPLATE }, viteConfig) { function createInput({ pages = [], template = DEFAULT_TEMPLATE }: UserOptions, viteConfig: any) {
const input = {}; const input: Record<string, string> = {};
if (isMpa(viteConfig) || pages?.length) { if (isMpa(viteConfig) || pages?.length) {
const templates = pages.map(page => page.template); const templates = pages.map(page => page.template);
templates.forEach((temp) => { templates?.forEach((temp) => {
let dirName = path.dirname(temp); if (temp) {
const file = path.basename(temp); let dirName = path.dirname(temp);
dirName = dirName.replace(/\s+/g, '').replace(/\//g, '-'); const file = path.basename(temp);
const key = dirName === '.' || dirName === 'public' || !dirName ? file.replace(/\.html/, '') : dirName; dirName = dirName.replace(/\s+/g, '').replace(/\//g, '-');
input[key] = path.resolve(viteConfig.root, temp); const key = dirName === '.' || dirName === 'public' || !dirName ? file.replace(/\.html/, '') : dirName;
input[key] = path.resolve(viteConfig?.root || '', temp);
}
}); });
return input; return input;
} }
const dir = path.dirname(template); const dir = path.dirname(template || DEFAULT_TEMPLATE);
if (ignoreDirs.includes(dir)) if (ignoreDirs.includes(dir)) {
return undefined; return undefined;
}
const file = path.basename(template); const file = path.basename(template || DEFAULT_TEMPLATE);
const key = file.replace(/\.html/, ''); const key = file.replace(/\.html/, '');
return { return {
[key]: path.resolve(viteConfig.root, template), [key]: path.resolve(viteConfig?.root || '', template || DEFAULT_TEMPLATE),
}; };
} }
async function renderHtml(html, config) { async function renderHtml(html: string, config: any): Promise<string> {
const { injectOptions, viteConfig, env, entry, verbose } = config; const { injectOptions, viteConfig, env, entry, verbose } = config;
const { data, ejsOptions } = injectOptions; const { data, ejsOptions } = injectOptions || {};
const ejsData = { const ejsData = {
...(viteConfig?.env ?? {}), ...(viteConfig?.env ?? {}),
...(viteConfig?.define ?? {}), ...(viteConfig?.define ?? {}),
@ -225,43 +279,45 @@ async function renderHtml(html, config) {
return result; return result;
} }
function getPage({ pages = [], entry, template = DEFAULT_TEMPLATE, inject = {} }, name, viteConfig) { function getPage(userOptions: UserOptions, name: string, viteConfig: ResolvedConfig | undefined) {
const { pages = [], entry, template = DEFAULT_TEMPLATE, inject = {} } = userOptions;
let page; let page;
if (isMpa(viteConfig) || pages?.length) if (isMpa(viteConfig) || pages?.length) {
page = getPageConfig(name, pages, DEFAULT_TEMPLATE); page = getPageConfig(name, pages, DEFAULT_TEMPLATE);
}
else else { page = createSpaPage(entry, template, inject); }
page = createSpaPage(entry, template, inject);
return page; return page;
} }
function isMpa(viteConfig) { function isMpa(viteConfig: ResolvedConfig | undefined): boolean {
const input = viteConfig?.build?.rollupOptions?.input ?? undefined; const input = viteConfig?.build?.rollupOptions?.input ?? undefined;
return typeof input !== 'string' && Object.keys(input || {}).length > 1; return typeof input !== 'string' && Object.keys(input || {}).length > 1;
} }
function removeEntryScript(html, verbose = false) { function removeEntryScript(html: string, verbose = false): string {
if (!html) if (!html) {
return html; return html;
}
const root = parse(html); const root = parse(html);
const scriptNodes = root.querySelectorAll('script[type=module]') || []; const scriptNodes = root.querySelectorAll('script[type=module]') || [];
const removedNode = []; const removedNode: string[] = [];
scriptNodes.forEach((item) => { scriptNodes.forEach((item) => {
removedNode.push(item.toString()); removedNode.push(item.toString());
item.parentNode.removeChild(item); item.parentNode.removeChild(item);
}); });
verbose if (verbose && removedNode.length) {
&& removedNode.length consola.warn(`vite-plugin-html: Since you have already configured entry, ${dim(
&& consola.warn(`vite-plugin-html: Since you have already configured entry, ${dim(
removedNode.toString(), removedNode.toString(),
)} is deleted. You may also delete it from the index.html. )} is deleted. You may also delete it from the index.html.
`); `);
}
return root.toString(); return root.toString();
} }
function createSpaPage(entry, template, inject = {}) { function createSpaPage(entry: string | undefined, template: string, inject: InjectOptions = {}): Page {
return { return {
entry, entry,
filename: 'index.html', filename: 'index.html',
@ -270,7 +326,7 @@ function createSpaPage(entry, template, inject = {}) {
}; };
} }
function getPageConfig(htmlName, pages, defaultPage) { function getPageConfig(htmlName: string, pages: Page[], defaultPage: string) {
const defaultPageOption = { const defaultPageOption = {
filename: defaultPage, filename: defaultPage,
template: `./${defaultPage}`, template: `./${defaultPage}`,
@ -279,15 +335,16 @@ function getPageConfig(htmlName, pages, defaultPage) {
return page ?? defaultPageOption ?? undefined; return page ?? defaultPageOption ?? undefined;
} }
function createRewire(reg, page, baseUrl, proxyUrlKeys) { function createRewire(reg: string, page: Page, baseUrl: string, proxyUrlKeys: string[]): Rewrites {
return { return {
from: new RegExp(`^/${reg}*`), from: new RegExp(`^/${reg}*`),
to({ parsedUrl }) { to({ parsedUrl }: { parsedUrl: ParsedUrl }) {
const pathname = parsedUrl.pathname; const pathname = parsedUrl.pathname;
const excludeBaseUrl = pathname.replace(baseUrl, '/'); const excludeBaseUrl = pathname.replace(baseUrl, '/');
const template = path.resolve(baseUrl, page.template); const template = path.resolve(baseUrl, page.template || '');
if (excludeBaseUrl === '/') if (excludeBaseUrl === '/') {
return template; return template;
}
const isApiUrl = proxyUrlKeys.some(item => pathname.startsWith(path.resolve(baseUrl, item))); const isApiUrl = proxyUrlKeys.some(item => pathname.startsWith(path.resolve(baseUrl, item)));
return isApiUrl ? parsedUrl.path : template; return isApiUrl ? parsedUrl.path : template;
@ -297,7 +354,7 @@ function createRewire(reg, page, baseUrl, proxyUrlKeys) {
const htmlFilter = createFilter(['**/*.html']); const htmlFilter = createFilter(['**/*.html']);
function getOptions(_minify) { function getOptions(_minify: boolean) {
return { return {
collapseWhitespace: _minify, collapseWhitespace: _minify,
keepClosingSlash: _minify, keepClosingSlash: _minify,
@ -310,27 +367,30 @@ function getOptions(_minify) {
}; };
} }
async function minifyHtml(html, minify$1) { async function minifyHtml(html: string, minifyOptions: boolean | any): Promise<string> {
if (typeof minify$1 === 'boolean' && !minify$1) if (typeof minifyOptions === 'boolean' && !minifyOptions) {
return html; return html;
}
let minifyOptions = minify$1; let minifyConfig = minifyOptions;
if (typeof minify$1 === 'boolean' && minify$1) if (typeof minifyOptions === 'boolean' && minifyOptions) {
minifyOptions = getOptions(minify$1); minifyConfig = getOptions(minifyOptions);
}
const res = await minify(html, minifyOptions); const res = await minify(html, minifyConfig);
return res; return res;
} }
function createMinifyHtmlPlugin({ _minify = true } = {}) { function createMinifyHtmlPlugin({ _minify = true } = {}): Plugin {
return { return {
name: 'vite:minify-html', name: 'vite:minify-html',
order: 'post', apply: 'build',
async generateBundle(_, outBundle) { async generateBundle(_, outBundle) {
if (_minify) { if (_minify) {
for (const bundle of Object.values(outBundle)) { for (const bundle of Object.values(outBundle)) {
if (bundle.type === 'asset' && htmlFilter(bundle.fileName) && typeof bundle.source === 'string') if (bundle.type === 'asset' && htmlFilter(bundle.fileName) && typeof bundle.source === 'string') {
bundle.source = await minifyHtml(bundle.source, _minify); bundle.source = await minifyHtml(bundle.source, _minify);
}
} }
} }
}, },
@ -339,8 +399,9 @@ function createMinifyHtmlPlugin({ _minify = true } = {}) {
consola.wrapConsole(); consola.wrapConsole();
function createHtmlPlugin(userOptions = {}) { function createHtmlPlugin(userOptions: UserOptions = {}): Plugin[] {
return [createPlugin(userOptions), createMinifyHtmlPlugin(userOptions)]; return [createPlugin(userOptions), createMinifyHtmlPlugin(userOptions)];
} }
export { createHtmlPlugin }; export { createHtmlPlugin };
export type { InjectOptions, Page, UserOptions };

View File

@ -1,8 +1,12 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
import process from 'node:process';
import { visualizer } from 'rollup-plugin-visualizer';
export default (api: IPluginAPI<{ viteAnalyze: Record<string, any> }>) => {
api.describe({ api.describe({
key: 'viteAnalyze', key: 'viteAnalyze',
config: { config: {
schema(joi) { schema(joi: any) {
return joi.object(); return joi.object();
}, },
default: {}, default: {},
@ -10,14 +14,14 @@ export default (api) => {
enableBy: () => !!process.env.ANALYZE, enableBy: () => !!process.env.ANALYZE,
}); });
api.modifyBundleConfig((memo) => { api.modifyBundleConfig((memo: any) => {
memo.plugins.push( memo.plugins.push(
require('rollup-plugin-visualizer').visualizer({ visualizer({
filename: './.cache/visualizer/stats.html', filename: './.cache/visualizer/stats.html',
open: true, open: true,
gzipSize: true, gzipSize: true,
brotliSize: true, brotliSize: true,
...api.viteAnalyze, ...api.config.viteAnalyze,
}), }),
); );

View File

@ -1,8 +1,11 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
import process from 'node:process';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'viteHtml', key: 'viteHtml',
config: { config: {
schema(joi) { schema(joi: any) {
return joi.object(); return joi.object();
}, },
default: {}, default: {},

View File

@ -1,8 +1,11 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
import process from 'node:process';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'viteLegacy', key: 'viteLegacy',
config: { config: {
schema(joi) { schema(joi: any) {
return joi.object(); return joi.object();
}, },
default: {}, default: {},

View File

@ -0,0 +1,22 @@
import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({
key: 'viteOption',
config: {
schema(joi: any) {
return joi.object();
},
default: {},
},
});
api.describe({
key: 'vite',
config: {
schema(joi: any) {
return joi.object();
},
default: {},
},
});
};

View File

@ -1,8 +1,10 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'viteVueJsx', key: 'viteVueJsx',
config: { config: {
schema(joi) { schema(joi: any) {
return joi.object(); return joi.object();
}, },
default: {}, default: {},

View File

@ -1,8 +1,10 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'viteVuePlugin', key: 'viteVuePlugin',
config: { config: {
schema(joi) { schema(joi: any) {
return joi.object(); return joi.object();
}, },
default: {}, default: {},

View File

@ -0,0 +1,30 @@
import { join } from 'node:path';
import { OWNER_DIR, ViteBuildConfig } from './shared';
interface BuilderPlugin {
plugins: string[];
}
export { ViteBuildConfig };
export default function (): BuilderPlugin {
return {
plugins: [
join(OWNER_DIR, 'dist/registerBuilder.mjs'),
join(OWNER_DIR, 'dist/registerMethods.mjs'),
join(OWNER_DIR, 'dist/registerType.mjs'),
// bundle configs
join(OWNER_DIR, 'dist/features/viteHtml.mjs'),
join(OWNER_DIR, 'dist/features/viteOption.mjs'),
join(OWNER_DIR, 'dist/features/viteVueJsx.mjs'),
join(OWNER_DIR, 'dist/features/viteVuePlugin.mjs'),
join(OWNER_DIR, 'dist/features/viteAnalyze.mjs'),
join(OWNER_DIR, 'dist/features/viteLegacy.mjs'),
// commands
join(OWNER_DIR, 'dist/commands/build/index.mjs'),
join(OWNER_DIR, 'dist/commands/dev/index.mjs'),
],
};
}

View File

@ -0,0 +1,7 @@
import type { IPluginAPI } from '@fesjs/shared';
export default function (api: IPluginAPI) {
api.registerBuilder({
name: 'vite',
});
}

View File

@ -1,4 +1,6 @@
export default function (api) { import type { IPluginAPI } from '@fesjs/shared';
export default function (api: IPluginAPI) {
['modifyBundleConfig'].forEach((name) => { ['modifyBundleConfig'].forEach((name) => {
api.registerMethod({ name }); api.registerMethod({ name });
}); });

View File

@ -1,6 +1,7 @@
import type { IPluginAPI } from '@fesjs/shared';
import { name } from '../package.json'; import { name } from '../package.json';
export default function (api) { export default function (api: IPluginAPI) {
api.addConfigType(() => ({ api.addConfigType(() => ({
source: name, source: name,
})); }));

View File

@ -0,0 +1,19 @@
import type { Options as PolyfillOptions } from '@vitejs/plugin-legacy';
import type { Options } from '@vitejs/plugin-vue';
import type createPlugin from '@vitejs/plugin-vue-jsx';
import type { HTMLOptions, UserConfig } from 'vite';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const OWNER_DIR: string = join(dirname(fileURLToPath(import.meta.url)), '..');
export interface ViteBuildConfig {
viteOption?: UserConfig;
vite?: UserConfig;
viteVuePlugin?: Options;
viteVueJsx?: Parameters<typeof createPlugin>[0];
viteLegacy?: PolyfillOptions;
viteHtml?: HTMLOptions;
viteAnalyze?: any;
viteOptionConfig?: UserConfig;
}

View File

@ -0,0 +1,9 @@
{
"extends": ["@fesjs/typescript-config/base.json"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./build"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,24 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: [
'src/index.ts',
'src/registerBuilder.ts',
'src/registerMethods.ts',
'src/registerType.ts',
'src/features/viteHtml.ts',
'src/features/viteOption.ts',
'src/features/viteVueJsx.ts',
'src/features/viteVuePlugin.ts',
'src/features/viteAnalyze.ts',
'src/features/viteLegacy.ts',
'src/commands/build/index.ts',
'src/commands/dev/index.ts',
],
splitting: false,
sourcemap: false,
clean: true,
dts: true,
shims: true,
format: ['esm'],
});

21
packages/builder-vite/types.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
import type { ServerOptions } from 'vite';
// eslint-disable-next-line antfu/no-import-dist
import type { ViteBuildConfig } from './dist/index.d.mjs';
declare module '@fesjs/fes' {
interface PluginBuildConfig extends ViteBuildConfig {
}
interface FesConfig {
terserOptions?: any;
inlineLimit?: number;
outputPath?: string;
proxy?: ServerOptions['proxy'];
title?: string;
mountElementId?: string;
publicPath?: string;
alias?: Record<string, string>;
autoprefixer?: any;
}
}

View File

@ -8,7 +8,7 @@
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/WeBankFinTech/fes.js.git", "url": "git+https://github.com/WeBankFinTech/fes.js.git",
"directory": "packages/fes-builder-webpack" "directory": "packages/builder-webpack"
}, },
"bugs": { "bugs": {
"url": "https://github.com/WeBankFinTech/fes.js/issues" "url": "https://github.com/WeBankFinTech/fes.js/issues"
@ -16,30 +16,35 @@
"keywords": [ "keywords": [
"fes" "fes"
], ],
"main": "lib/index.js", "main": "dist/index.mjs",
"types": "types.d.ts", "module": "dist/index.mjs",
"files": [ "files": [
"lib", "dist",
"types.d.ts" "types.d.ts"
], ],
"scripts": {
"dev": "tsup --watch --sourcemap",
"build": "tsup"
},
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"peerDependencies": { "peerDependencies": {
"@fesjs/fes": "^3.1.17", "@fesjs/fes": "^3.1.17",
"core-js": "^3.29.1" "core-js": "^3.45.1"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.23.2", "@babel/core": "^7.28.3",
"@babel/plugin-proposal-do-expressions": "^7.22.5", "@babel/plugin-proposal-do-expressions": "^7.22.5",
"@babel/plugin-proposal-export-default-from": "^7.22.17", "@babel/plugin-proposal-export-default-from": "^7.22.17",
"@babel/plugin-proposal-function-bind": "^7.22.5", "@babel/plugin-proposal-function-bind": "^7.22.5",
"@babel/plugin-proposal-pipeline-operator": "^7.22.15", "@babel/plugin-proposal-pipeline-operator": "^7.22.15",
"@babel/plugin-transform-runtime": "^7.23.2", "@babel/plugin-transform-runtime": "^7.28.3",
"@babel/preset-env": "^7.23.2", "@babel/preset-env": "^7.28.3",
"@babel/preset-typescript": "^7.23.2", "@babel/preset-typescript": "^7.27.1",
"@fesjs/utils": "^3.0.3", "@fesjs/shared": "workspace:*",
"@vue/babel-plugin-jsx": "^1.2.2", "@fesjs/utils": "workspace:*",
"@vue/babel-plugin-jsx": "^1.5.0",
"ajv": "^8.12.0", "ajv": "^8.12.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"babel-loader": "^9.1.2", "babel-loader": "^9.1.2",
@ -49,13 +54,13 @@
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^5.0.0", "css-minimizer-webpack-plugin": "^5.0.0",
"fs-extra": "^11.1.1", "fs-extra": "^11.3.1",
"get-folder-size": "^2.0.1", "get-folder-size": "^5.0.0",
"html-webpack-plugin": "^5.5.0", "html-webpack-plugin": "^5.5.0",
"html-webpack-tags-plugin": "^3.0.2", "html-webpack-tags-plugin": "^3.0.2",
"less": "^4.1.3", "less": "^4.1.3",
"less-loader": "^11.1.0", "less-loader": "^11.1.0",
"mini-css-extract-plugin": "^2.8.1", "mini-css-extract-plugin": "^2.9.4",
"postcss": "^8.4.33", "postcss": "^8.4.33",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-loader": "^7.1.0", "postcss-loader": "^7.1.0",
@ -63,8 +68,8 @@
"style-loader": "^3.3.2", "style-loader": "^3.3.2",
"terser-webpack-plugin": "^5.3.6", "terser-webpack-plugin": "^5.3.6",
"vue-loader": "^17.4.2", "vue-loader": "^17.4.2",
"webpack": "^5.90.3", "webpack": "^5.101.3",
"webpack-5-chain": "^8.0.1", "webpack-5-chain": "^8.0.2",
"webpack-bundle-analyzer": "^4.4.0", "webpack-bundle-analyzer": "^4.4.0",
"webpack-dev-server": "^5.1.0", "webpack-dev-server": "^5.1.0",
"webpackbar": "^7.0.0" "webpackbar": "^7.0.0"

View File

@ -0,0 +1,46 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { WebpackBuildConfig } from './shared';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export {
WebpackBuildConfig,
};
export default function () {
return {
plugins: [
join(__dirname, './plugins/registerBuilder.mjs'),
// register methods
join(__dirname, './plugins/registerMethods.mjs'),
join(__dirname, './plugins/registerType.mjs'),
// bundle configs
join(__dirname, './plugins/features/analyze.mjs'),
join(__dirname, './plugins/features/chainWebpack.mjs'),
join(__dirname, './plugins/features/cssLoader.mjs'),
join(__dirname, './plugins/features/copy.mjs'),
join(__dirname, './plugins/features/devServer.mjs'),
join(__dirname, './plugins/features/devtool.mjs'),
join(__dirname, './plugins/features/externals.mjs'),
join(__dirname, './plugins/features/exportStatic.mjs'),
join(__dirname, './plugins/features/extraBabelPlugins.mjs'),
join(__dirname, './plugins/features/extraBabelPresets.mjs'),
join(__dirname, './plugins/features/extraPostCSSPlugins.mjs'),
join(__dirname, './plugins/features/html.mjs'),
join(__dirname, './plugins/features/lessLoader.mjs'),
join(__dirname, './plugins/features/postcssLoader.mjs'),
join(__dirname, './plugins/features/nodeModulesTransform.mjs'),
join(__dirname, './plugins/features/vueLoader.mjs'),
join(__dirname, './plugins/features/extraCSS.mjs'),
// commands
join(__dirname, './plugins/commands/build/index.mjs'),
join(__dirname, './plugins/commands/dev/index.mjs'),
join(__dirname, './plugins/commands/webpack/index.mjs'),
],
};
}

View File

@ -1,9 +1,9 @@
import webpack from 'webpack'; import webpack from 'webpack';
export async function build({ bundleConfig }) { export async function build(bundleConfig: webpack.Configuration): Promise<{ stats?: webpack.Stats }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const compiler = webpack(bundleConfig); const compiler = webpack(bundleConfig);
compiler.run((err, stats) => { compiler!.run((err, stats) => {
if (err) { if (err) {
console.error(err); console.error(err);
return reject(new Error('build failed')); return reject(new Error('build failed'));

View File

@ -1,13 +1,12 @@
/** import type { IPluginAPI } from '@fesjs/shared';
* @copy umi import type { WebpackBuildConfig } from '../../../shared';
* https://github.com/umijs/umi/blob/master/packages/preset-built-in/src/plugins/commands/build/build.ts import { existsSync } from 'node:fs';
*/ import { relative } from 'node:path';
import process from 'node:process';
import { relative } from 'path';
import { existsSync } from 'fs';
import { cleanTmpPathExceptCache, getBundleAndConfigs, printFileSizes } from '../../common/buildDevUtils'; import { cleanTmpPathExceptCache, getBundleAndConfigs, printFileSizes } from '../../common/buildDevUtils';
import { build } from './build';
export default function (api) { export default function (api: IPluginAPI<WebpackBuildConfig>) {
const { const {
paths, paths,
utils: { rimraf, logger }, utils: { rimraf, logger },
@ -17,8 +16,6 @@ export default function (api) {
command: 'build', command: 'build',
description: 'build application for production', description: 'build application for production',
async fn() { async fn() {
const { build } = require('./build');
cleanTmpPathExceptCache({ cleanTmpPathExceptCache({
absTmpPath: paths.absTmpPath, absTmpPath: paths.absTmpPath,
}); });
@ -40,11 +37,13 @@ export default function (api) {
} }
} }
const { stats } = await build({ bundleConfig }); const { stats } = await build(bundleConfig);
if (process.env.RM_TMPDIR !== 'none') { if (process.env.RM_TMPDIR !== 'none') {
rimraf.sync(paths.absTmpPath); rimraf.sync(paths.absTmpPath);
} }
printFileSizes(stats, relative(process.cwd(), paths.absOutputPath)); if (stats) {
printFileSizes({ stats, dir: relative(process.cwd(), paths.absOutputPath) });
}
await api.applyPlugins({ await api.applyPlugins({
key: 'onBuildComplete', key: 'onBuildComplete',
type: api.ApplyPluginsType.event, type: api.ApplyPluginsType.event,
@ -52,7 +51,8 @@ export default function (api) {
stats, stats,
}, },
}); });
} catch (err) { }
catch (err) {
await api.applyPlugins({ await api.applyPlugins({
key: 'onBuildComplete', key: 'onBuildComplete',
type: api.ApplyPluginsType.event, type: api.ApplyPluginsType.event,

View File

@ -1,14 +1,16 @@
import type { IPluginAPI } from '@fesjs/shared';
import type { WebpackBuildConfig } from '../../../shared';
import { extname } from 'node:path'; import { extname } from 'node:path';
import historyFallback from 'connect-history-api-fallback'; import historyFallback from 'connect-history-api-fallback';
const ASSET_EXT_NAMES = ['.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg']; const ASSET_EXT_NAMES = ['.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg'];
function proxyMiddleware(api) { function proxyMiddleware(api: IPluginAPI<WebpackBuildConfig>) {
return (req, res, next) => { return (req: any, res: any, next: any) => {
const proxyConfig = api.config.proxy; const proxyConfig = api.config.proxy;
if (proxyConfig) { if (proxyConfig) {
if (Array.isArray(proxyConfig)) { if (Array.isArray(proxyConfig)) {
if (proxyConfig.some(item => item.context.some(path => path && req.url.startsWith(path)))) { if (proxyConfig.some(item => item.context.some((path: string) => path && req.url.startsWith(path)))) {
return next(); return next();
} }
} }

View File

@ -1,8 +1,20 @@
import type { WebpackBuildConfig } from '../../../shared';
import { chalk } from '@fesjs/utils'; import { chalk } from '@fesjs/utils';
import webpack from 'webpack'; import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server'; import WebpackDevServer from 'webpack-dev-server';
function formatProxy(proxy) { interface StartDevServerOptions {
webpackConfig: webpack.Configuration;
host: string;
port: number;
proxy: any;
https: boolean | { key: string; cert: string };
beforeMiddlewares: any[];
afterMiddlewares: any[];
customerDevServerConfig?: WebpackBuildConfig['devServer'];
}
function formatProxy(proxy: any) {
if (!proxy) { if (!proxy) {
return []; return [];
} }
@ -19,8 +31,11 @@ function formatProxy(proxy) {
}); });
} }
export function startDevServer({ webpackConfig, host, port, proxy, https, beforeMiddlewares, afterMiddlewares, customerDevServerConfig }) { export function startDevServer({ webpackConfig, host, port, proxy, https, beforeMiddlewares, afterMiddlewares, customerDevServerConfig }: StartDevServerOptions) {
const options = { const headers: Record<string, string> = {
'access-control-allow-origin': '*',
};
const options: WebpackDevServer.Configuration = {
hot: true, hot: true,
allowedHosts: 'all', allowedHosts: 'all',
server: https ? 'https' : 'http', server: https ? 'https' : 'http',
@ -39,15 +54,17 @@ export function startDevServer({ webpackConfig, host, port, proxy, https, before
return middlewares; return middlewares;
}, },
headers: { // @ts-expect-error 不知道这里为啥异常
'access-control-allow-origin': '*', headers,
},
...(customerDevServerConfig || {}), ...(customerDevServerConfig || {}),
port, port,
host, host,
proxy: formatProxy(proxy), proxy: formatProxy(proxy),
}; };
const compiler = webpack(webpackConfig); const compiler = webpack(webpackConfig);
if (!compiler) {
throw new Error('Failed to create webpack compiler');
}
const server = new WebpackDevServer(options, compiler); const server = new WebpackDevServer(options, compiler);
if (options.host === '0.0.0.0') { if (options.host === '0.0.0.0') {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

View File

@ -1,38 +1,40 @@
import type { IPluginAPI } from '@fesjs/shared';
import type { WebpackBuildConfig } from '../../../shared';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { removeSync } from 'fs-extra/esm';
import getFolderSize from 'get-folder-size';
import { cleanTmpPathExceptCache, getBundleAndConfigs } from '../../common/buildDevUtils'; import { cleanTmpPathExceptCache, getBundleAndConfigs } from '../../common/buildDevUtils';
import connectHistoryMiddleware from './connectHistoryMiddleware'; import connectHistoryMiddleware from './connectHistoryMiddleware';
import { startDevServer } from './devServer';
async function handleCacheClean(cwd) { async function handleCacheClean(cwd: string) {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
const cachePath = path.join(cwd, '.cache/webpack'); const cachePath = path.join(cwd, '.cache/webpack');
if (!fs.existsSync(cachePath)) { if (!fs.existsSync(cachePath)) {
return resolve(); return resolve(0);
} }
require('get-folder-size')(cachePath, (err, size) => { // 大于 5G 清除缓存,修复 webpack 缓存无限增长问题
if (err) { // https://github.com/webpack/webpack/issues/13291
return reject(err); getFolderSize.loose(cachePath).then((size) => {
}
// 大于 5G 清除缓存,修复 webpack 缓存无限增长问题
// https://github.com/webpack/webpack/issues/13291
if (size > 5 * 1024 * 1024 * 1024) { if (size > 5 * 1024 * 1024 * 1024) {
require('fs-extra').removeSync(cachePath); removeSync(cachePath);
} }
resolve(size); resolve(size);
}); });
}); });
} }
export default (api) => { export default (api: IPluginAPI<WebpackBuildConfig>) => {
const { const {
paths, paths,
utils: { chalk, getPort, getHostName, changePort, logger }, utils: { chalk, getPort, getHostName, changePort, logger },
} = api; } = api;
let port; let port: number;
let hostname; let hostname: string;
let server; let server: any;
async function destroy() { async function destroy() {
await server?.stop(); await server?.stop();
@ -90,7 +92,6 @@ export default (api) => {
args: {}, args: {},
}); });
const { startDevServer } = require('./devServer');
server = startDevServer({ server = startDevServer({
webpackConfig: bundleConfig, webpackConfig: bundleConfig,
host: hostname, host: hostname,
@ -113,7 +114,7 @@ export default (api) => {
fn() { fn() {
logger.info(chalk.gray('Try to restart dev server...')); logger.info(chalk.gray('Try to restart dev server...'));
destroy(); destroy();
process.send({ process.send?.({
type: 'RESTART', type: 'RESTART',
}); });
}, },

View File

@ -0,0 +1,9 @@
const pitcher = (code: string): string => code;
export function pitch(this: any) {
if (/&blockType=config/.test(this.resourceQuery)) {
return '';
}
}
export default pitcher;

View File

@ -1,6 +1,10 @@
import type { IPluginAPI } from '@fesjs/shared';
import type { WebpackBuildConfig } from '../../../shared';
import { assert } from 'node:console';
import { highlight } from 'cli-highlight';
import { getBundleAndConfigs } from '../../common/buildDevUtils'; import { getBundleAndConfigs } from '../../common/buildDevUtils';
export default function (api) { export default function (api: IPluginAPI<WebpackBuildConfig>) {
api.registerCommand({ api.registerCommand({
command: 'webpack', command: 'webpack',
description: 'inspect webpack configurations', description: 'inspect webpack configurations',
@ -26,26 +30,27 @@ export default function (api) {
description: 'show full function definitions in output', description: 'show full function definitions in output',
}, },
], ],
async fn({ options }) { async fn({ options }: { options: any }) {
const assert = require('assert');
const { toString } = require('webpack-5-chain');
const { highlight } = require('cli-highlight');
const { bundleConfig } = await getBundleAndConfigs({ api }); const { bundleConfig } = await getBundleAndConfigs({ api });
let config = bundleConfig; let config: any = bundleConfig;
assert(config, 'No valid config found with fes entry.'); assert(config, 'No valid config found with fes entry.');
if (options.rule) { if (options.rule) {
config = config.module.rules.find((r) => r.__ruleNames[0] === options.rule); config = config.module.rules.find((r: any) => r.__ruleNames[0] === options.rule);
} else if (options.plugin) { }
config = config.plugins.find((p) => p.__pluginName === options.plugin); else if (options.plugin) {
} else if (options.rules) { config = config.plugins.find((p: any) => p.__pluginName === options.plugin);
config = config.module.rules.map((r) => r.__ruleNames[0]); }
} else if (options.plugins) { else if (options.rules) {
config = config.plugins.map((p) => p.__pluginName || p.constructor.name); config = config.module.rules.map((r: any) => r.__ruleNames[0]);
}
else if (options.plugins) {
config = config.plugins.map((p: any) => p.__pluginName || p.constructor.name);
} }
console.log(highlight(toString(config, { verbose: options.verbose }), { language: 'js' })); // eslint-disable-next-line no-console
console.log(highlight(config.toString({ verbose: options.verbose }), { language: 'js' }));
}, },
}); });
} }

View File

@ -1,18 +1,25 @@
/** import type { IPluginAPI } from '@fesjs/shared';
* @copy umi import type webpack from 'webpack';
* https://github.com/umijs/umi/blob/master/packages/preset-built-in/src/plugins/commands/buildDevUtils.ts import type { WebpackBuildConfig } from '../../shared';
*/ import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { join, resolve } from 'path'; import zlib from 'node:zlib';
import { existsSync, readFileSync } from 'fs'; import { chalk, rimraf } from '@fesjs/utils';
import zlib from 'zlib'; import UI from 'cliui';
import { rimraf, chalk } from '@fesjs/utils';
import getConfig from './webpackConfig'; import getConfig from './webpackConfig';
export async function getBundleAndConfigs({ api }) { interface GetBundleAndConfigsOptions {
api: IPluginAPI<WebpackBuildConfig>;
}
interface GetBundleAndConfigsResult {
bundleConfig: webpack.Configuration;
}
export async function getBundleAndConfigs({ api }: GetBundleAndConfigsOptions): Promise<GetBundleAndConfigsResult> {
// get config // get config
const env = api.env === 'production' ? 'production' : 'development'; const env = api.env === 'production' ? 'production' : 'development';
const getConfigOpts = await api.applyPlugins({ const getConfigOpts: any = await api.applyPlugins({
type: api.ApplyPluginsType.modify, type: api.ApplyPluginsType.modify,
key: 'modifyBundleConfigOpts', key: 'modifyBundleConfigOpts',
initialValue: { initialValue: {
@ -22,22 +29,21 @@ export async function getBundleAndConfigs({ api }) {
entry: { entry: {
index: join(api.paths.absTmpPath, 'fes.js'), index: join(api.paths.absTmpPath, 'fes.js'),
}, },
// @ts-ignore async modifyBabelOpts(opts: any) {
async modifyBabelOpts(opts) {
return api.applyPlugins({ return api.applyPlugins({
type: api.ApplyPluginsType.modify, type: api.ApplyPluginsType.modify,
key: 'modifyBabelOpts', key: 'modifyBabelOpts',
initialValue: opts, initialValue: opts,
}); });
}, },
async modifyBabelPresetOpts(opts) { async modifyBabelPresetOpts(opts: any) {
return api.applyPlugins({ return api.applyPlugins({
type: api.ApplyPluginsType.modify, type: api.ApplyPluginsType.modify,
key: 'modifyBabelPresetOpts', key: 'modifyBabelPresetOpts',
initialValue: opts, initialValue: opts,
}); });
}, },
async chainWebpack(webpackConfig, opts) { async chainWebpack(webpackConfig: any, opts: any) {
return api.applyPlugins({ return api.applyPlugins({
type: api.ApplyPluginsType.modify, type: api.ApplyPluginsType.modify,
key: 'chainWebpack', key: 'chainWebpack',
@ -64,7 +70,7 @@ export async function getBundleAndConfigs({ api }) {
args: {}, args: {},
}); });
const bundleConfig = await api.applyPlugins({ const bundleConfig: webpack.Configuration = await api.applyPlugins({
type: api.ApplyPluginsType.modify, type: api.ApplyPluginsType.modify,
key: 'modifyBundleConfig', key: 'modifyBundleConfig',
initialValue: await getConfig({ api, ...getConfigOpts }), initialValue: await getConfig({ api, ...getConfigOpts }),
@ -74,7 +80,11 @@ export async function getBundleAndConfigs({ api }) {
return { bundleConfig }; return { bundleConfig };
} }
export function cleanTmpPathExceptCache({ absTmpPath }) { interface CleanTmpPathExceptCacheOptions {
absTmpPath: string;
}
export function cleanTmpPathExceptCache({ absTmpPath }: CleanTmpPathExceptCacheOptions) {
rimraf.sync(absTmpPath); rimraf.sync(absTmpPath);
} }
@ -82,15 +92,20 @@ export function cleanTmpPathExceptCache({ absTmpPath }) {
const WARN_AFTER_BUNDLE_GZIP_SIZE = 1.8 * 1024 * 1024; const WARN_AFTER_BUNDLE_GZIP_SIZE = 1.8 * 1024 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1 * 1024 * 1024; const WARN_AFTER_CHUNK_GZIP_SIZE = 1 * 1024 * 1024;
export function printFileSizes(stats, dir) { interface PrintFileSizesOptions {
const ui = require('cliui')({ width: 80 }); stats: webpack.Stats;
const json = stats.toJson({ dir: string;
}
export function printFileSizes({ stats, dir }: PrintFileSizesOptions) {
const ui = UI({ width: 80 });
const json: any = stats.toJson({
hash: false, hash: false,
modules: false, modules: false,
chunks: false, chunks: false,
}); });
const filesize = (bytes) => { const filesize = (bytes: number) => {
bytes = Math.abs(bytes); bytes = Math.abs(bytes);
const radix = 1024; const radix = 1024;
const unit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const unit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
@ -104,14 +119,14 @@ export function printFileSizes(stats, dir) {
return `${bytes.toFixed(1)} ${unit[loop]}`; return `${bytes.toFixed(1)} ${unit[loop]}`;
}; };
const assets = json.assets ? json.assets : json?.children?.reduce((acc, child) => acc.concat(child?.assets), []); const assets: any[] = json.assets ? json.assets : json?.children?.reduce((acc: any, child: any) => acc.concat(child?.assets), []);
const seenNames = new Map(); const seenNames = new Map();
const isJS = (val) => /\.js$/.test(val); const isJS = (val: string) => /\.js$/.test(val);
const isCSS = (val) => /\.css$/.test(val); const isCSS = (val: string) => /\.css$/.test(val);
const orderedAssets = assets const orderedAssets: any[] = assets
.map((a) => { .map((a: any) => {
a.name = a.name.split('?')[0]; a.name = a.name.split('?')[0];
// These sizes are pretty large // These sizes are pretty large
const isMainBundle = a.name.indexOf('fes.') === 0; const isMainBundle = a.name.indexOf('fes.') === 0;
@ -122,20 +137,24 @@ export function printFileSizes(stats, dir) {
suggested: isLarge && isJS(a.name), suggested: isLarge && isJS(a.name),
}; };
}) })
.filter((a) => { .filter((a: any) => {
if (seenNames.has(a.name)) { if (seenNames.has(a.name)) {
return false; return false;
} }
seenNames.set(a.name, true); seenNames.set(a.name, true);
return isJS(a.name) || isCSS(a.name); return isJS(a.name) || isCSS(a.name);
}) })
.sort((a, b) => { .sort((a: any, b: any) => {
if (isJS(a.name) && isCSS(b.name)) return -1; if (isJS(a.name) && isCSS(b.name)) {
if (isCSS(a.name) && isJS(b.name)) return 1; return -1;
}
if (isCSS(a.name) && isJS(b.name)) {
return 1;
}
return b.size - a.size; return b.size - a.size;
}); });
function getGzippedSize(asset) { function getGzippedSize(asset: any) {
const filepath = resolve(join(dir, asset.name)); const filepath = resolve(join(dir, asset.name));
if (existsSync(filepath)) { if (existsSync(filepath)) {
const buffer = readFileSync(filepath); const buffer = readFileSync(filepath);
@ -144,15 +163,15 @@ export function printFileSizes(stats, dir) {
return filesize(0); return filesize(0);
} }
function makeRow(a, b, c) { function makeRow(a: string, b: string, c: string) {
return ` ${a}\t ${b}\t ${c}`; return ` ${a}\t ${b}\t ${c}`;
} }
ui.div( ui.div(
`${makeRow(chalk.cyan.bold('File'), chalk.cyan.bold('Size'), chalk.cyan.bold('Gzipped'))}\n\n${orderedAssets `${makeRow(chalk.cyan.bold('File'), chalk.cyan.bold('Size'), chalk.cyan.bold('Gzipped'))}\n\n${orderedAssets
.map((asset) => .map((asset: any) =>
makeRow( makeRow(
/js$/.test(asset.name) asset.name.endsWith('js')
? asset.suggested ? asset.suggested
? chalk.yellow(join(dir, asset.name)) ? chalk.yellow(join(dir, asset.name))
: chalk.green(join(dir, asset.name)) : chalk.green(join(dir, asset.name))
@ -164,13 +183,19 @@ export function printFileSizes(stats, dir) {
.join('\n')}`, .join('\n')}`,
); );
// eslint-disable-next-line no-console
console.log(`${ui.toString()}\n\n ${chalk.gray('Images and other types of assets omitted.')}\n`); console.log(`${ui.toString()}\n\n ${chalk.gray('Images and other types of assets omitted.')}\n`);
if (orderedAssets?.some((asset) => asset.suggested)) { if (orderedAssets?.some((asset: any) => asset.suggested)) {
// eslint-disable-next-line no-console
console.log(); console.log();
// eslint-disable-next-line no-console
console.log(chalk.yellow('The bundle size is significantly larger than recommended.')); console.log(chalk.yellow('The bundle size is significantly larger than recommended.'));
// eslint-disable-next-line no-console
console.log(chalk.yellow('Consider reducing it with code splitting')); console.log(chalk.yellow('Consider reducing it with code splitting'));
// eslint-disable-next-line no-console
console.log(chalk.yellow('You can also analyze the project dependencies using ANALYZE=1')); console.log(chalk.yellow('You can also analyze the project dependencies using ANALYZE=1'));
// eslint-disable-next-line no-console
console.log(); console.log();
} }
} }

View File

@ -1,33 +1,41 @@
// css less post-css mini-css css 压缩 import type Config from 'webpack-5-chain';
// extraPostCSSPlugins import type { WebpackBuildConfig } from '../../../shared';
// postcssLoader
// lessLoader
// css-loader
// 支持 热加载
// 性能优化
// css 压缩 https://github.com/webpack-contrib/css-minimizer-webpack-plugin
// 根据 entry 进行代码块拆分
// 根据 entry 将文件输出到不同的文件夹
import { deepmerge } from '@fesjs/utils'; import { deepmerge } from '@fesjs/utils';
import { esmRequire, esmResolve } from '../../../shared';
function createRules({ isDev, webpackConfig, config, lang, test, loader, options, browserslist, styleLoaderOption }) { interface CreateRulesOptions {
function applyLoaders(rule, cssLoaderOption = {}) { isDev: boolean;
webpackConfig: Config;
config: WebpackBuildConfig;
lang: string;
test: RegExp;
loader?: string;
options?: any;
browserslist: string[];
styleLoaderOption?: any;
}
interface ApplyLoadersOptions {
modules?: {
localIdentName: string;
};
}
function createRules({ isDev, webpackConfig, config, lang, test, loader, options, browserslist, styleLoaderOption }: CreateRulesOptions) {
function applyLoaders(rule: any, cssLoaderOption: ApplyLoadersOptions = {}) {
if (isDev || !config.extraCSS) { if (isDev || !config.extraCSS) {
rule.use('extra-css-loader').loader(require.resolve('style-loader')).options(Object.assign({}, styleLoaderOption)); rule.use('extra-css-loader').loader(esmResolve('style-loader')).options(Object.assign({}, styleLoaderOption));
} }
else { else {
const loaderOptions = config.extraCSS?.loader ?? {}; const loaderOptions = config.extraCSS?.loader ?? {};
if (!loaderOptions.publicPath && config.publicPath.startsWith('./')) {
loaderOptions.publicPath = '../';
}
rule.use('extra-css-loader') rule.use('extra-css-loader')
.loader(require('mini-css-extract-plugin').loader) .loader(esmRequire('mini-css-extract-plugin').loader)
.options(loaderOptions); .options(loaderOptions);
} }
rule.use('css-loader') rule.use('css-loader')
.loader(require.resolve('css-loader')) .loader(esmResolve('css-loader'))
.options( .options(
deepmerge( deepmerge(
{ {
@ -39,16 +47,16 @@ function createRules({ isDev, webpackConfig, config, lang, test, loader, options
); );
rule.use('postcss-loader') rule.use('postcss-loader')
.loader(require.resolve('postcss-loader')) .loader(esmResolve('postcss-loader'))
.options( .options(
deepmerge( deepmerge(
{ {
postcssOptions: () => ({ postcssOptions: () => ({
plugins: [ plugins: [
// https://github.com/luisrudge/postcss-flexbugs-fixes // https://github.com/luisrudge/postcss-flexbugs-fixes
require('postcss-flexbugs-fixes'), esmRequire('postcss-flexbugs-fixes'),
require('postcss-safe-parser'), esmRequire('postcss-safe-parser'),
[require('autoprefixer'), { ...config.autoprefixer, overrideBrowserslist: browserslist }], [esmRequire('autoprefixer'), { overrideBrowserslist: browserslist }],
...(config.extraPostCSSPlugins ? config.extraPostCSSPlugins : []), ...(config.extraPostCSSPlugins ? config.extraPostCSSPlugins : []),
], ],
}), }),
@ -58,7 +66,7 @@ function createRules({ isDev, webpackConfig, config, lang, test, loader, options
); );
if (loader) { if (loader) {
rule.use(loader).loader(require.resolve(loader)).options(options); rule.use(loader).loader(esmResolve(loader)).options(options);
} }
} }
@ -71,7 +79,14 @@ function createRules({ isDev, webpackConfig, config, lang, test, loader, options
applyLoaders(rule.oneOf('css')); applyLoaders(rule.oneOf('css'));
} }
export default function createCssWebpackConfig({ isDev, config, webpackConfig, browserslist }) { interface CreateCssWebpackConfigOptions {
isDev: boolean;
config: WebpackBuildConfig;
webpackConfig: Config;
browserslist: string[];
}
export default function createCssWebpackConfig({ isDev, config, webpackConfig, browserslist }: CreateCssWebpackConfigOptions) {
createRules({ createRules({
isDev, isDev,
webpackConfig, webpackConfig,
@ -98,7 +113,7 @@ export default function createCssWebpackConfig({ isDev, config, webpackConfig, b
}); });
if (!isDev && config.extraCSS) { if (!isDev && config.extraCSS) {
webpackConfig.plugin('extra-css').use(require.resolve('mini-css-extract-plugin'), [ webpackConfig.plugin('extra-css').use(esmResolve('mini-css-extract-plugin'), [
Object.assign( Object.assign(
{ {
filename: 'static/[name].[contenthash:8].css', filename: 'static/[name].[contenthash:8].css',
@ -110,16 +125,16 @@ export default function createCssWebpackConfig({ isDev, config, webpackConfig, b
} }
if (!isDev) { if (!isDev) {
webpackConfig.optimization.minimizer('css').use(require.resolve('css-minimizer-webpack-plugin'), [{}]); webpackConfig.optimization.minimizer('css').use(esmResolve('css-minimizer-webpack-plugin'), [{}]);
} }
return (options) => { return (options: Partial<CreateRulesOptions>) => {
createRules({ createRules({
isDev, isDev,
config, config,
webpackConfig, webpackConfig,
browserslist, browserslist,
...options, ...options,
}); } as CreateRulesOptions);
}; };
} }

View File

@ -1,13 +1,21 @@
import webpack from 'webpack'; import type Config from 'webpack-5-chain';
import type { WebpackBuildConfig } from '../../../shared';
import { resolveRuntimeEnv, stringifyObjValue } from '@fesjs/utils'; import { resolveRuntimeEnv, stringifyObjValue } from '@fesjs/utils';
import webpack from 'webpack';
export default function createDefineWebpackConfig({ config, publicPath, webpackConfig }) { interface CreateDefineWebpackConfigOptions {
const env = stringifyObjValue(resolveRuntimeEnv(publicPath)); config: WebpackBuildConfig;
publicPath?: string;
webpackConfig: Config;
}
export default function createDefineWebpackConfig({ config, publicPath, webpackConfig }: CreateDefineWebpackConfigOptions) {
const env = stringifyObjValue(resolveRuntimeEnv(publicPath || ''));
const define = stringifyObjValue({ const define = stringifyObjValue({
__VUE_OPTIONS_API__: true, __VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false, __VUE_PROD_DEVTOOLS__: false,
...config.define, ...(config as any).define,
}); });
webpackConfig.plugin('define').use(webpack.DefinePlugin, [ webpackConfig.plugin('define').use(webpack.DefinePlugin, [

View File

@ -1,14 +1,28 @@
import type { WebpackBuildConfig } from '../../../shared';
import process from 'node:process';
import { winPath } from '@fesjs/utils'; import { winPath } from '@fesjs/utils';
import { esmRequire, esmResolve } from '../../../shared';
function getBabelOpts({ cwd, targets, config, presetOpts }) { interface PresetOpts {
const presets = [ transformRuntime: Record<string, any>;
}
interface GetBabelOptsOptions {
cwd: string;
targets: Record<string, string>;
config: WebpackBuildConfig;
presetOpts: PresetOpts;
}
function getBabelOpts({ cwd, targets, config, presetOpts }: GetBabelOptsOptions) {
const presets: any[] = [
[ [
require.resolve('@babel/preset-env'), esmResolve('@babel/preset-env'),
{ {
targets, targets,
useBuiltIns: 'usage', useBuiltIns: 'usage',
corejs: { corejs: {
version: require('core-js/package.json').version, version: esmRequire('core-js/package.json').version,
proposals: true, proposals: true,
}, },
modules: false, modules: false,
@ -16,7 +30,7 @@ function getBabelOpts({ cwd, targets, config, presetOpts }) {
], ],
[ [
// FEATURE 实现类型安全检查 // FEATURE 实现类型安全检查
require('@babel/preset-typescript').default, esmRequire('@babel/preset-typescript').default,
{ {
// https://babeljs.io/docs/en/babel-plugin-transform-typescript#impartial-namespace-support // https://babeljs.io/docs/en/babel-plugin-transform-typescript#impartial-namespace-support
allowNamespaces: true, allowNamespaces: true,
@ -26,24 +40,24 @@ function getBabelOpts({ cwd, targets, config, presetOpts }) {
], ],
...(config.extraBabelPresets || []), ...(config.extraBabelPresets || []),
]; ];
const plugins = [ const plugins: any[] = [
require('@babel/plugin-proposal-export-default-from').default, esmRequire('@babel/plugin-proposal-export-default-from').default,
[ [
require('@babel/plugin-proposal-pipeline-operator').default, esmRequire('@babel/plugin-proposal-pipeline-operator').default,
{ {
proposal: 'minimal', proposal: 'minimal',
}, },
], ],
require('@babel/plugin-proposal-do-expressions').default, esmRequire('@babel/plugin-proposal-do-expressions').default,
require('@babel/plugin-proposal-function-bind').default, esmRequire('@babel/plugin-proposal-function-bind').default,
[ [
require.resolve('@babel/plugin-transform-runtime'), esmResolve('@babel/plugin-transform-runtime'),
{ {
useESModules: true, useESModules: true,
...presetOpts.transformRuntime, ...presetOpts.transformRuntime,
}, },
], ],
require.resolve('@vue/babel-plugin-jsx'), esmResolve('@vue/babel-plugin-jsx'),
...(config.extraBabelPlugins || []), ...(config.extraBabelPlugins || []),
]; ];
return { return {
@ -61,8 +75,16 @@ function getBabelOpts({ cwd, targets, config, presetOpts }) {
}; };
} }
export default async ({ cwd, config, modifyBabelOpts, modifyBabelPresetOpts, targets }) => { interface ExportDefaultOptions {
let presetOpts = { cwd: string;
config: WebpackBuildConfig;
modifyBabelOpts?: (opts: any) => Promise<any>;
modifyBabelPresetOpts?: (opts: PresetOpts) => Promise<PresetOpts>;
targets: Record<string, string>;
}
export default async ({ cwd, config, modifyBabelOpts, modifyBabelPresetOpts, targets }: ExportDefaultOptions) => {
let presetOpts: PresetOpts = {
transformRuntime: {}, transformRuntime: {},
}; };
if (modifyBabelPresetOpts) { if (modifyBabelPresetOpts) {

View File

@ -0,0 +1,107 @@
import type { IPluginAPI } from '@fesjs/shared';
import type { WebpackBuildConfig } from '../../../shared';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveRuntimeEnv, winPath } from '@fesjs/utils';
import { esmResolve } from '../../../shared';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
interface CreateHtmlWebpackConfigOptions {
api: IPluginAPI<WebpackBuildConfig>;
cwd: string;
config: WebpackBuildConfig;
webpackConfig: any;
headScripts?: () => Promise<Array<{ src: string }>>;
isProd: boolean;
publicPath?: string;
}
interface Route {
path: string;
meta?: {
title?: string;
};
children?: Route[];
}
export default async function createHtmlWebpackConfig({ api, cwd, config, webpackConfig, headScripts, isProd, publicPath }: CreateHtmlWebpackConfigOptions) {
const htmlOptions: any = {
filename: '[name].html',
...config.html,
templateParameters: {
title: (api.config as any).title || config.html?.title || 'fes.js',
...resolveRuntimeEnv(publicPath || ''),
mountElementId: (config as any).mountElementId,
},
};
if (isProd) {
Object.assign(htmlOptions, {
minify: {
removeComments: true,
collapseWhitespace: true,
collapseBooleanAttributes: true,
removeScriptTypeAttributes: true,
// more options:
// https://github.com/kangax/html-minifier#options-quick-reference
},
});
}
const htmlPath = join(cwd, 'index.html');
const defaultHtmlPath = join(__dirname, '../index-default.html');
const publicCopyIgnore: string[] = [];
// default, single page setup.
htmlOptions.template = existsSync(htmlPath) ? htmlPath : defaultHtmlPath;
publicCopyIgnore.push(winPath(htmlOptions.template));
webpackConfig.plugin('html').use(esmResolve('html-webpack-plugin'), [htmlOptions]);
// 如果需要导出html则根据路由生成对应的html文件
if ((config as any).exportStatic) {
const routes: Route[] = await (api as any).getRoutes();
const addHtml = (_routes: Route[] | undefined) => {
if (Array.isArray(_routes)) {
_routes.forEach((route) => {
const _fileName = `${route.path.slice(1) || 'index'}.html`;
if (_fileName !== 'index.html') {
const _htmlOptions = {
...config.html,
filename: _fileName,
templateParameters: {
title: route?.meta?.title || config.html?.title || (api.config as any).title || 'fes.js',
...resolveRuntimeEnv(publicPath!),
mountElementId: (config as any).mountElementId,
},
};
webpackConfig.plugin(_fileName).use(esmResolve('html-webpack-plugin'), [_htmlOptions]);
}
if (route.children && route.children.length) {
addHtml(route.children);
}
});
}
};
addHtml(routes);
}
if (headScripts) {
const headScriptsMap = await headScripts();
webpackConfig.plugin('html-tags').use(esmResolve('html-webpack-tags-plugin'), [
{
append: false,
scripts: headScriptsMap.map(script => ({
path: script.src,
})),
},
]);
}
return {
publicCopyIgnore,
};
}

View File

@ -1,7 +1,12 @@
import type { IPluginAPI } from '@fesjs/shared';
import type { WebpackBuildConfig } from '../../../shared';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { platform } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import webpack from 'webpack'; import webpack from 'webpack';
import Config from 'webpack-5-chain'; import Config from 'webpack-5-chain';
import pkg from '../../../../package.json' assert { type: 'json' };
import { esmResolve } from '../../../shared';
import createCssWebpackConfig from './css'; import createCssWebpackConfig from './css';
import createDefineWebpackConfig from './define'; import createDefineWebpackConfig from './define';
import getBabelOpts from './getBabelOpts'; import getBabelOpts from './getBabelOpts';
@ -22,32 +27,54 @@ const DEFAULT_EXCLUDE_NODE_MODULES = [
'html-entities', 'html-entities',
]; ];
function genTranspileDepRegex(exclude) { function genTranspileDepRegex(exclude: (string | RegExp)[]) {
exclude = exclude.concat(DEFAULT_EXCLUDE_NODE_MODULES); exclude = exclude.concat(DEFAULT_EXCLUDE_NODE_MODULES);
const deps = exclude.map((dep) => { const deps = exclude.map((dep) => {
if (typeof dep === 'string') { if (typeof dep === 'string') {
const depPath = join('node_modules', dep, '/'); const depPath = join('node_modules', dep, '/');
return require('node:os').platform().startsWith('win') ? depPath.replace(/\\/g, '\\\\') : depPath; return platform().startsWith('win') ? depPath.replace(/\\/g, '\\\\') : depPath;
}
if (dep instanceof RegExp) {
return dep.source;
} }
if (dep instanceof RegExp) { return dep.source; }
throw new Error('exclude only accepts an array of string or regular expressions'); throw new Error('exclude only accepts an array of string or regular expressions');
}); });
return deps.length ? new RegExp(deps.join('|')) : null; return deps.length ? new RegExp(deps.join('|')) : null;
} }
function handleAlias({ api, webpackConfig }) { interface HandleAliasOptions {
api: IPluginAPI<WebpackBuildConfig>;
webpackConfig: Config;
}
function handleAlias({ api, webpackConfig }: HandleAliasOptions) {
const config = api.config; const config = api.config;
if (config.alias) { if (config.alias) {
Object.keys(config.alias).forEach((key) => { Object.keys(config.alias).forEach((key) => {
webpackConfig.resolve.alias.set(key, config.alias[key]); webpackConfig.resolve.alias.set(key, config.alias![key]);
}); });
} }
webpackConfig.resolve.alias.set('@', api.paths.absSrcPath); webpackConfig.resolve.alias.set('@', api.paths.absSrcPath);
webpackConfig.resolve.alias.set('@@', api.paths.absTmpPath); webpackConfig.resolve.alias.set('@@', api.paths.absTmpPath);
} }
export default async function getConfig({ api, cwd, config, env, entry = {}, modifyBabelOpts, modifyBabelPresetOpts, chainWebpack, headScripts, publicPath }) { interface GetConfigOptions {
api: IPluginAPI<WebpackBuildConfig>;
cwd: string;
config: IPluginAPI<WebpackBuildConfig>['config'];
env: 'development' | 'production';
entry?: Record<string, string>;
modifyBabelOpts?: (opts: any) => Promise<any>;
modifyBabelPresetOpts?: (opts: any) => Promise<any>;
chainWebpack?: (webpackConfig: Config, opts: any) => Promise<void>;
headScripts?: () => Promise<any[]>;
publicPath?: string;
}
export default async function getConfig(
{ api, cwd, config, env, entry = {}, modifyBabelOpts, modifyBabelPresetOpts, chainWebpack, headScripts, publicPath }: GetConfigOptions,
): Promise<webpack.Configuration> {
const isDev = env === 'development'; const isDev = env === 'development';
const isProd = env === 'production'; const isProd = env === 'production';
const webpackConfig = new Config(); const webpackConfig = new Config();
@ -56,12 +83,15 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
webpackConfig.mode(env); webpackConfig.mode(env);
webpackConfig.stats('errors-only'); webpackConfig.stats('errors-only');
webpackConfig.externals(config.externals || {}); webpackConfig.externals(config.externals || {});
webpackConfig.devtool(isDev ? config.devtool || 'cheap-module-source-map' : config.devtool); const devtool = isDev ? config.devtool || 'cheap-module-source-map' : config.devtool;
if (devtool) {
webpackConfig.devtool(devtool);
}
// --------------- cache ----------- // --------------- cache -----------
webpackConfig.cache({ webpackConfig.cache({
type: 'filesystem', type: 'filesystem',
version: require('../../../../package.json').version, version: pkg.version,
cacheDirectory: join(cwd, '.cache/webpack'), cacheDirectory: join(cwd, '.cache/webpack'),
}); });
@ -74,7 +104,7 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
// --------------- output ----------- // --------------- output -----------
webpackConfig.output webpackConfig.output
.path(absoluteOutput) .path(absoluteOutput)
.publicPath(publicPath) .publicPath(publicPath || '')
.filename('static/[name].[contenthash:8].js') .filename('static/[name].[contenthash:8].js')
.chunkFilename('static/[name].[contenthash:8].chunk.js') .chunkFilename('static/[name].[contenthash:8].chunk.js')
.assetModuleFilename('static/[name][hash:8][ext]'); .assetModuleFilename('static/[name][hash:8][ext]');
@ -131,21 +161,23 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
.rule('js') .rule('js')
.test(/\.(js|mjs|jsx|ts|tsx)$/) .test(/\.(js|mjs|jsx|ts|tsx)$/)
.exclude .exclude
.add((filepath) => { .add((filepath: string) => {
// always transpile js in vue files // always transpile js in vue files
if (/(\.vue|\.jsx)$/.test(filepath)) { return false; } if (/\.vue$|\.jsx$/.test(filepath)) {
return false;
}
// Don't transpile node_modules // Don't transpile node_modules
return /node_modules/.test(filepath); return /node_modules/.test(filepath);
}) })
.end() .end()
.use('babel-loader') .use('babel-loader')
.loader(require.resolve('babel-loader')) .loader(esmResolve('babel-loader'))
.options(babelOpts); .options(babelOpts);
// 为了避免第三方依赖包编译不充分导致线上问题,默认对 node_modules 也进行全编译,只在生产构建的时候进行 // 为了避免第三方依赖包编译不充分导致线上问题,默认对 node_modules 也进行全编译,只在生产构建的时候进行
if (isProd) { if (isProd) {
const transpileDepRegex = genTranspileDepRegex(config.nodeModulesTransform.exclude); const transpileDepRegex = genTranspileDepRegex(config.nodeModulesTransform?.exclude || []);
webpackConfig.module webpackConfig.module
.rule('js-in-node_modules') .rule('js-in-node_modules')
.test(/\.(js|mjs)$/) .test(/\.(js|mjs)$/)
@ -153,14 +185,16 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
.add(/node_modules/) .add(/node_modules/)
.end() .end()
.exclude .exclude
.add((filepath) => { .add((filepath: string) => {
if (transpileDepRegex && transpileDepRegex.test(filepath)) { return true; } if (transpileDepRegex && transpileDepRegex.test(filepath)) {
return true;
}
return false; return false;
}) })
.end() .end()
.use('babel-loader') .use('babel-loader')
.loader(require.resolve('babel-loader')) .loader(esmResolve('babel-loader'))
.options(babelOpts); .options(babelOpts);
} }
@ -190,22 +224,27 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
}); });
// --------------- copy ----------- // --------------- copy -----------
const copyFiles = Array.isArray(config.copy) ? config.copy : config.copy ? [config.copy] : [];
const copyPatterns = [ const copyPatterns = [
existsSync(join(cwd, 'public')) && { existsSync(join(cwd, 'public')) && {
from: join(cwd, 'public'), from: join(cwd, 'public'),
filter: (resourcePath) => { filter: (resourcePath: string) => {
if (resourcePath.includes('.DS_Store')) { return false; } if (resourcePath.includes('.DS_Store')) {
return false;
}
if (publicCopyIgnore.includes(resourcePath)) { return false; } if (publicCopyIgnore.includes(resourcePath)) {
return false;
}
return true; return true;
}, },
to: absoluteOutput, to: absoluteOutput,
}, },
...(config.copy || []).map((item) => { ...copyFiles.map((item: any) => {
if (typeof item === 'string') { if (typeof item === 'string') {
return { return {
from: join(cwd, item.from), from: join(cwd, item),
to: absoluteOutput, to: absoluteOutput,
}; };
} }
@ -217,14 +256,14 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
].filter(Boolean); ].filter(Boolean);
// const publicCopyIgnore = ['.DS_Store']; // const publicCopyIgnore = ['.DS_Store'];
if (copyPatterns.length) { if (copyPatterns.length) {
webpackConfig.plugin('copy').use(require.resolve('copy-webpack-plugin'), [ webpackConfig.plugin('copy').use(esmResolve('copy-webpack-plugin'), [
{ {
patterns: copyPatterns, patterns: copyPatterns,
}, },
]); ]);
} }
webpackConfig.plugin('progress').use(require.resolve(require.resolve('webpackbar'))); webpackConfig.plugin('progress').use(esmResolve('webpackbar'));
// --------------- define ----------- // --------------- define -----------
createDefineWebpackConfig({ createDefineWebpackConfig({
@ -282,7 +321,7 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
}); });
} }
const memo = webpackConfig.toConfig(); const memo: webpack.Configuration = webpackConfig.toConfig();
memo.infrastructureLogging = { memo.infrastructureLogging = {
level: 'error', level: 'error',
...memo.infrastructureLogging, ...memo.infrastructureLogging,

View File

@ -1,4 +1,8 @@
import type Config from 'webpack-5-chain';
import type { WebpackBuildConfig } from '../../../shared';
import process from 'node:process';
import { deepmerge } from '@fesjs/utils'; import { deepmerge } from '@fesjs/utils';
import { esmResolve } from '../../../shared';
const defaultTerserOptions = { const defaultTerserOptions = {
compress: { compress: {
@ -37,14 +41,22 @@ const defaultTerserOptions = {
}, },
}; };
const terserOptions = (config) => ({ function terserOptions(config: WebpackBuildConfig) {
terserOptions: deepmerge(defaultTerserOptions, config.terserOptions || {}), return {
extractComments: false, terserOptions: deepmerge(defaultTerserOptions, (config as any).terserOptions || {}),
}); extractComments: false,
};
}
export default function createMinimizerWebpackConfig({ isProd, config, webpackConfig }) { interface CreateMinimizerWebpackConfigOptions {
isProd: boolean;
config: WebpackBuildConfig;
webpackConfig: Config;
}
export default function createMinimizerWebpackConfig({ isProd, config, webpackConfig }: CreateMinimizerWebpackConfigOptions) {
if (isProd) { if (isProd) {
webpackConfig.optimization.minimizer('terser').use(require.resolve('terser-webpack-plugin'), [terserOptions(config)]); webpackConfig.optimization.minimizer('terser').use(esmResolve('terser-webpack-plugin'), [terserOptions(config)]);
} }
if (process.env.FES_ENV === 'test') { if (process.env.FES_ENV === 'test') {
webpackConfig.optimization.minimize(false); webpackConfig.optimization.minimize(false);

View File

@ -0,0 +1,35 @@
import type Config from 'webpack-5-chain';
import type { WebpackBuildConfig } from '../../../shared';
import { join } from 'node:path';
import { esmRequire, esmResolve, OWNER_DIR } from '../../../shared';
interface CreateVueWebpackConfigOptions {
config: WebpackBuildConfig;
webpackConfig: Config;
}
export default function createVueWebpackConfig({ config, webpackConfig }: CreateVueWebpackConfigOptions) {
webpackConfig.module
.rule('vue')
.test(/\.vue$/)
.use('vue-loader')
.loader(esmResolve('vue-loader'))
.options({
babelParserPlugins: ['jsx', 'classProperties', 'decorators-legacy'],
...(config as any).vueLoader || {},
})
.end();
webpackConfig.module
.rule('vue-custom')
.resourceQuery((query: string) => {
if (!query) {
return false;
}
return query.startsWith('?vue&type=custom');
})
.use('vue-custom-loader')
.loader(join(OWNER_DIR, './pitcher.mjs'));
webpackConfig.plugin('vue-loader-plugin').use(esmRequire('vue-loader').VueLoaderPlugin);
}

View File

@ -1,4 +1,10 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
import type Config from 'webpack-5-chain';
import type { WebpackBuildConfig } from '../../shared';
import process from 'node:process';
import { esmRequire } from '../../shared';
export default (api: IPluginAPI<WebpackBuildConfig>) => {
api.describe({ api.describe({
key: 'analyze', key: 'analyze',
config: { config: {
@ -29,8 +35,8 @@ export default (api) => {
}, },
enableBy: () => !!process.env.ANALYZE, enableBy: () => !!process.env.ANALYZE,
}); });
api.chainWebpack((webpackConfig) => { (api as any).chainWebpack((webpackConfig: Config) => {
webpackConfig.plugin('bundle-analyzer').use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [api.config?.analyze || {}]); webpackConfig.plugin('bundle-analyzer').use(esmRequire('webpack-bundle-analyzer').BundleAnalyzerPlugin, [api.config?.analyze || {}]);
return webpackConfig; return webpackConfig;
}); });
}; };

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'chainWebpack', key: 'chainWebpack',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'copy', key: 'copy',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'cssLoader', key: 'cssLoader',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'devServer', key: 'devServer',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'devtool', key: 'devtool',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'exportStatic', key: 'exportStatic',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'externals', key: 'externals',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'extraBabelPlugins', key: 'extraBabelPlugins',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'extraBabelPresets', key: 'extraBabelPresets',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'extraCSS', key: 'extraCSS',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'extraPostCSSPlugins', key: 'extraPostCSSPlugins',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'html', key: 'html',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'lessLoader', key: 'lessLoader',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'nodeModulesTransform', key: 'nodeModulesTransform',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'postcssLoader', key: 'postcssLoader',
config: { config: {

View File

@ -1,4 +1,6 @@
export default (api) => { import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({ api.describe({
key: 'vueLoader', key: 'vueLoader',
config: { config: {

View File

@ -0,0 +1,7 @@
import type { IPluginAPI } from '@fesjs/shared';
export default function (api: IPluginAPI) {
api.registerBuilder({
name: 'webpack',
});
}

View File

@ -1,4 +1,6 @@
export default function (api) { import type { IPluginAPI } from '@fesjs/shared';
export default function (api: IPluginAPI) {
[ [
'addHTMLHeadScripts', 'addHTMLHeadScripts',
'modifyBundleConfigOpts', 'modifyBundleConfigOpts',

View File

@ -1,6 +1,7 @@
import type { IPluginAPI } from '@fesjs/shared';
import { name } from '../../package.json'; import { name } from '../../package.json';
export default function (api) { export default function (api: IPluginAPI) {
api.addConfigType(() => ({ api.addConfigType(() => ({
source: name, source: name,
})); }));

View File

@ -0,0 +1,72 @@
import type HtmlWebpackPlugin from 'html-webpack-plugin';
import type { LoaderOptions, PluginOptions } from 'mini-css-extract-plugin';
import type Config from 'webpack-5-chain';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export interface CopyFileType {
from: string;
to: string;
}
export const OWNER_DIR: string = join(dirname(fileURLToPath(import.meta.url)), '..');
export const esmRequire = createRequire(import.meta.url);
export function esmResolve(specifier: string) {
const esmRequire = createRequire(import.meta.url);
return esmRequire.resolve(specifier);
}
export interface WebpackBuildConfig {
analyze?: {
analyzerMode?: 'server' | 'static' | 'disabled';
analyzerHost?: string;
analyzerPort?: number | 'auto';
openAnalyzer?: boolean;
generateStatsFile?: boolean;
statsFilename?: string;
logLevel?: 'info' | 'warn' | 'error' | 'silent';
defaultSizes?: 'stat' | 'parsed' | 'gzip';
};
chainWebpack?: (memo: Config, args: any) => void;
copy?: CopyFileType | CopyFileType[];
cssLoader?: {
url?: boolean | ((url: string, resourcePath: string) => boolean);
import?: boolean | { filter: (url: string, media: string, resourcePath: string) => boolean };
modules?: boolean | string | object;
sourceMap?: boolean;
importLoaders?: number;
onlyLocals?: boolean;
esModule?: boolean;
localsConvention?: 'asIs' | 'camelCase' | 'camelCaseOnly' | 'dashes' | 'dashesOnly';
};
devServer?: {
port?: number;
host?: string;
https?: boolean;
headers?: object;
[key: string]: any;
};
devtool?: Config.DevTool;
exportStatic?: {
htmlSuffix?: boolean;
dynamicRoot?: boolean;
};
externals?: string | ((data: any) => any);
extraBabelPlugins?: [];
extraBabelPresets?: [];
extraPostCSSPlugins?: [];
html?: HtmlWebpackPlugin.Options;
lessLoader?: Record<string, any>;
nodeModulesTransform?: {
exclude: string[];
};
postcssLoader?: Record<string, any>;
vueLoader?: object;
extraCSS?: {
loader?: LoaderOptions;
plugin?: PluginOptions;
};
}

View File

@ -1,12 +1,30 @@
import assert from 'assert'; import assert from 'node:assert';
import path from 'path'; import path from 'node:path';
import { lodash, winPath } from '@fesjs/utils'; import { lodash, winPath } from '@fesjs/utils';
interface SpecifierObject {
local: string;
exported: string;
}
type Specifier = string | SpecifierObject;
interface Item {
source: string;
exportAll?: boolean;
specifiers?: Specifier[];
}
interface GenerateExportsOptions {
item: Item;
fesExportsHook: Record<string, boolean>;
}
const reserveLibrarys = ['fes']; // reserve library const reserveLibrarys = ['fes']; // reserve library
// todo 插件导出内容冲突问题待解决 // todo 插件导出内容冲突问题待解决
const reserveExportsNames = ['Link', 'NavLink', 'Redirect', 'dynamic', 'withRouter', 'Route']; const reserveExportsNames = ['Link', 'NavLink', 'Redirect', 'dynamic', 'withRouter', 'Route'];
export default function generateExports(basePath, { item, fesExportsHook }) { export default function generateExports(basePath: string, { item, fesExportsHook }: GenerateExportsOptions): string {
assert(item.source, 'source should be supplied.'); assert(item.source, 'source should be supplied.');
const source = path.relative(path.basename(basePath), item.source); const source = path.relative(path.basename(basePath), item.source);
assert(item.exportAll || item.specifiers, 'exportAll or specifiers should be supplied.'); assert(item.exportAll || item.specifiers, 'exportAll or specifiers should be supplied.');
@ -14,8 +32,8 @@ export default function generateExports(basePath, { item, fesExportsHook }) {
if (item.exportAll) { if (item.exportAll) {
return `export * from '${winPath(source)}';`; return `export * from '${winPath(source)}';`;
} }
assert(Array.isArray(item.specifiers), `specifiers should be Array, but got ${item.specifiers.toString()}.`); assert(Array.isArray(item.specifiers), `specifiers should be Array, but got ${item.specifiers?.toString()}.`);
const specifiersStrArr = item.specifiers.map((specifier) => { const specifiersStrArr = item.specifiers!.map((specifier) => {
if (typeof specifier === 'string') { if (typeof specifier === 'string') {
assert(!reserveExportsNames.includes(specifier), `${specifier} is reserve name, you can use 'exported' to set alias.`); assert(!reserveExportsNames.includes(specifier), `${specifier} is reserve name, you can use 'exported' to set alias.`);
assert(!fesExportsHook[specifier], `${specifier} is Defined, you can use 'exported' to set alias.`); assert(!fesExportsHook[specifier], `${specifier} is Defined, you can use 'exported' to set alias.`);
@ -23,8 +41,8 @@ export default function generateExports(basePath, { item, fesExportsHook }) {
return specifier; return specifier;
} }
assert(lodash.isPlainObject(specifier), `Configure item context should be Plain Object, but got ${specifier}.`); assert(lodash.isPlainObject(specifier), `Configure item context should be Plain Object, but got ${specifier}.`);
assert(specifier.local && specifier.exported, 'local and exported should be supplied.'); assert((specifier as SpecifierObject).local && (specifier as SpecifierObject).exported, 'local and exported should be supplied.');
return `${specifier.local} as ${specifier.exported}`; return `${(specifier as SpecifierObject).local} as ${(specifier as SpecifierObject).exported}`;
}); });
return `export { ${specifiersStrArr.join(', ')} } from '${winPath(source)}';`; return `export { ${specifiersStrArr.join(', ')} } from '${winPath(source)}';`;
} }

View File

@ -0,0 +1,9 @@
{
"extends": ["@fesjs/typescript-config/base.json"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./build"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,38 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: [
'src/index.ts',
'src/plugins/commands/pitcher.ts',
'src/plugins/registerBuilder.ts',
'src/plugins/registerMethods.ts',
'src/plugins/registerType.ts',
'src/plugins/features/analyze.ts',
'src/plugins/features/chainWebpack.ts',
'src/plugins/features/cssLoader.ts',
'src/plugins/features/copy.ts',
'src/plugins/features/devServer.ts',
'src/plugins/features/devtool.ts',
'src/plugins/features/externals.ts',
'src/plugins/features/exportStatic.ts',
'src/plugins/features/extraBabelPlugins.ts',
'src/plugins/features/extraBabelPresets.ts',
'src/plugins/features/extraPostCSSPlugins.ts',
'src/plugins/features/html.ts',
'src/plugins/features/lessLoader.ts',
'src/plugins/features/postcssLoader.ts',
'src/plugins/features/nodeModulesTransform.ts',
'src/plugins/features/vueLoader.ts',
'src/plugins/features/extraCSS.ts',
'src/plugins/commands/build/index.ts',
'src/plugins/commands/dev/index.ts',
'src/plugins/commands/webpack/index.ts',
],
splitting: false,
sourcemap: false,
clean: true,
dts: true,
shims: true,
format: ['esm'],
onSuccess: 'cp -r src/plugins/commands/index-default.html dist/plugins/commands/index-default.html',
});

6
packages/builder-webpack/types.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
// eslint-disable-next-line antfu/no-import-dist
import type { WebpackBuildConfig } from './dist/index.d.mts';
declare module '@fesjs/fes' {
interface PluginBuildConfig extends WebpackBuildConfig {}
}

View File

@ -35,16 +35,13 @@ Fes.js 是一个好用的前端应用解决方案。提供覆盖编译构建到
| [@fesjs/plugin-access](http://fesjs.mumblefe.cn/reference/plugin/plugins/access.html) | 提供对页面资源的权限控制能力 | | [@fesjs/plugin-access](http://fesjs.mumblefe.cn/reference/plugin/plugins/access.html) | 提供对页面资源的权限控制能力 |
| [@fesjs/plugin-enums](http://fesjs.mumblefe.cn/reference/plugin/plugins/enums.html#%E4%BB%8B%E7%BB%8D) | 提供统一的枚举存取及丰富的函数来处理枚举 | | [@fesjs/plugin-enums](http://fesjs.mumblefe.cn/reference/plugin/plugins/enums.html#%E4%BB%8B%E7%BB%8D) | 提供统一的枚举存取及丰富的函数来处理枚举 |
| [@fesjs/plugin-icon](http://fesjs.mumblefe.cn/reference/plugin/plugins/icon.html#%E4%BB%8B%E7%BB%8D) | svg 文件自动注册为组件 | | [@fesjs/plugin-icon](http://fesjs.mumblefe.cn/reference/plugin/plugins/icon.html#%E4%BB%8B%E7%BB%8D) | svg 文件自动注册为组件 |
| [@fesjs/plugin-jest](http://fesjs.mumblefe.cn/reference/plugin/plugins/jest.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | 基于 `Jest`,提供单元测试、覆盖测试能力 |
| [ @fesjs/plugin-layout](http://fesjs.mumblefe.cn/reference/plugin/plugins/layout.html) | 简单的配置即可拥有布局,包括导航以及侧边栏 | | [ @fesjs/plugin-layout](http://fesjs.mumblefe.cn/reference/plugin/plugins/layout.html) | 简单的配置即可拥有布局,包括导航以及侧边栏 |
| [@fesjs/plugin-locale](http://fesjs.mumblefe.cn/reference/plugin/plugins/locale.html#%E4%BB%8B%E7%BB%8D) | 基于 `Vue I18n`,提供国际化能力 | | [@fesjs/plugin-locale](http://fesjs.mumblefe.cn/reference/plugin/plugins/locale.html#%E4%BB%8B%E7%BB%8D) | 基于 `Vue I18n`,提供国际化能力 |
| [@fesjs/plugin-model](http://fesjs.mumblefe.cn/reference/plugin/plugins/model.html#%E4%BB%8B%E7%BB%8D) | 简易的数据管理方案 | | [@fesjs/plugin-model](http://fesjs.mumblefe.cn/reference/plugin/plugins/model.html#%E4%BB%8B%E7%BB%8D) | 简易的数据管理方案 |
| [@fesjs/plugin-request](http://fesjs.mumblefe.cn/reference/plugin/plugins/request.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | 基于 `Axios` 封装的 request内置防止重复请求、请求节流、错误处理等功能 | | [@fesjs/plugin-request](http://fesjs.mumblefe.cn/reference/plugin/plugins/request.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | 基于 `Axios` 封装的 request内置防止重复请求、请求节流、错误处理等功能 |
| [@fesjs/plugin-vuex](http://fesjs.mumblefe.cn/reference/plugin/plugins/vuex.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | 基于 `Vuex`, 提供状态管理能力 |
| [@fesjs/plugin-qiankun](http://fesjs.mumblefe.cn/reference/plugin/plugins/qiankun.html#%E4%BB%8B%E7%BB%8D) | 基于 `qiankun`,提供微服务能力 | | [@fesjs/plugin-qiankun](http://fesjs.mumblefe.cn/reference/plugin/plugins/qiankun.html#%E4%BB%8B%E7%BB%8D) | 基于 `qiankun`,提供微服务能力 |
| [@fesjs/plugin-sass](http://fesjs.mumblefe.cn/reference/plugin/plugins/sass.html#%E4%BB%8B%E7%BB%8D) | 样式支持 sass | | [@fesjs/plugin-sass](http://fesjs.mumblefe.cn/reference/plugin/plugins/sass.html#%E4%BB%8B%E7%BB%8D) | 样式支持 sass |
| [@fesjs/plugin-monaco-editor](http://fesjs.mumblefe.cn/reference/plugin/plugins/editor.html#%E4%BB%8B%E7%BB%8D) | 提供代码编辑器能力, 基于`monaco-editor`VS Code 使用的代码编辑器) | | [@fesjs/plugin-monaco-editor](http://fesjs.mumblefe.cn/reference/plugin/plugins/editor.html#%E4%BB%8B%E7%BB%8D) | 提供代码编辑器能力, 基于`monaco-editor`VS Code 使用的代码编辑器) |
| [@fesjs/plugin-windicss](http://fesjs.mumblefe.cn/reference/plugin/plugins/windicss.html) | 基于 `windicss`,提供原子化 CSS 能力 |
| [@fesjs/plugin-pinia](http://fesjs.mumblefe.cn/reference/plugin/plugins/pinia.html) | pinia状态处理 | | [@fesjs/plugin-pinia](http://fesjs.mumblefe.cn/reference/plugin/plugins/pinia.html) | pinia状态处理 |
| [@fesjs/plugin-watermark](http://fesjs.mumblefe.cn/reference/plugin/plugins/watermark.html) | 水印 | | [@fesjs/plugin-watermark](http://fesjs.mumblefe.cn/reference/plugin/plugins/watermark.html) | 水印 |

View File

@ -8,7 +8,7 @@
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/WeBankFinTech/fes.js.git", "url": "git+https://github.com/WeBankFinTech/fes.js.git",
"directory": "packages/fes-compiler" "directory": "packages/compiler"
}, },
"bugs": { "bugs": {
"url": "https://github.com/WeBankFinTech/fes.js/issues" "url": "https://github.com/WeBankFinTech/fes.js/issues"
@ -16,21 +16,31 @@
"keywords": [ "keywords": [
"fes" "fes"
], ],
"main": "lib/index.js", "main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "dist/index.d.mts",
"files": [ "files": [
"lib" "dist"
], ],
"scripts": {
"dev": "tsup --watch --sourcemap",
"build": "tsup"
},
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.23.2", "@babel/core": "^7.28.3",
"@babel/preset-env": "^7.23.2", "@babel/preset-env": "^7.28.3",
"@babel/register": "^7.22.15", "@fesjs/utils": "workspace:*",
"@fesjs/utils": "^3.0.3",
"commander": "^7.0.0", "commander": "^7.0.0",
"dotenv": "8.2.0", "dotenv": "8.2.0",
"fs-extra": "^11.3.1",
"joi": "17.3.0", "joi": "17.3.0",
"package-up": "^5.0.0",
"tapable": "^2.2.0" "tapable": "^2.2.0"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4"
} }
} }

View File

@ -1,71 +1,91 @@
/** import type { ServiceInstance, UserConfig } from '../types';
* @copy umi import assert from 'node:assert';
* https://github.com/umijs/umi/tree/master/packages/core import { existsSync } from 'node:fs';
*/ import { extname, join } from 'node:path';
import process from 'node:process';
import { existsSync } from 'fs'; import { chalk, chokidar, compatESModuleRequire, deepmerge, lodash, winPath } from '@fesjs/utils';
import { extname, join } from 'path';
import assert from 'assert';
import { chalk, chokidar, compatESModuleRequire, deepmerge, cleanRequireCache, lodash, parseRequireDeps, winPath } from '@fesjs/utils';
import joi from 'joi'; import joi from 'joi';
import { ServiceStage } from '../service/enums'; import { ServiceStage } from '../service/enums';
import { getUserConfigWithKey, updateUserConfigWithKey } from './utils/configUtils'; import { getUserConfigWithKey, updateUserConfigWithKey } from './utils/configUtils';
import isEqual from './utils/isEqual'; import isEqual from './utils/isEqual';
import mergeDefault from './utils/mergeDefault'; import mergeDefault from './utils/mergeDefault';
const CONFIG_FILES = ['.fes.js']; interface ConfigOptions {
cwd?: string;
service: ServiceInstance;
localConfig?: boolean;
}
interface WatchOptions {
userConfig: UserConfig;
onChange: (params: {
userConfig: UserConfig;
pluginChanged: Array<{ key: string; pluginId: string }>;
valueChanged: Array<{ key: string; pluginId: string }>;
}) => void;
}
const CONFIG_FILES: string[] = ['.fes.js'];
export default class Config { export default class Config {
cwd; cwd: string;
service; service: ServiceInstance;
config; config: any;
localConfig; localConfig: boolean;
configFile; configFile: string[];
constructor(opts) { constructor(opts: ConfigOptions) {
this.cwd = opts.cwd || process.cwd(); this.cwd = opts.cwd || process.cwd();
this.service = opts.service; this.service = opts.service;
this.localConfig = opts.localConfig; this.localConfig = opts.localConfig || false;
this.configFile = [];
this.config = null;
} }
async getDefaultConfig() { async getDefaultConfig(): Promise<Record<string, any>> {
const pluginIds = Object.keys(this.service.plugins); const pluginIds = Object.keys(this.service.plugins);
// collect default config // collect default config
const defaultConfig = pluginIds.reduce((memo, pluginId) => { const defaultConfig = pluginIds.reduce((memo: Record<string, any>, pluginId: string) => {
const { key, config = {} } = this.service.plugins[pluginId]; const { key, config = {} } = this.service.plugins[pluginId];
if ('default' in config) memo[key] = config.default; if ('default' in config) {
memo[key] = config.default;
}
return memo; return memo;
}, {}); }, {});
return defaultConfig; return defaultConfig;
} }
getConfig({ defaultConfig }) { async getConfig(defaultConfig: Record<string, any>): Promise<UserConfig> {
assert(this.service.stage >= ServiceStage.pluginReady, 'Config.getConfig() failed, it should not be executed before plugin is ready.'); assert(this.service.stage >= ServiceStage.pluginReady, 'Config.getConfig() failed, it should not be executed before plugin is ready.');
const userConfig = this.getUserConfig(); const userConfig = await this.getUserConfig();
// 用于提示用户哪些 key 是未定义的 // 用于提示用户哪些 key 是未定义的
// TODO: 考虑不排除 false 的 key // TODO: 考虑不排除 false 的 key
const userConfigKeys = Object.keys(userConfig).filter((key) => userConfig[key] !== false); const userConfigKeys = Object.keys(userConfig).filter(key => userConfig[key] !== false);
// get config // get config
const pluginIds = Object.keys(this.service.plugins); const pluginIds = Object.keys(this.service.plugins);
pluginIds.forEach((pluginId) => { pluginIds.forEach((pluginId: string) => {
const { key, config = {} } = this.service.plugins[pluginId]; const { key, config = {} } = this.service.plugins[pluginId];
// recognize as key if have schema config // recognize as key if have schema config
if (!config.schema) return; if (!config.schema) {
return;
}
const value = getUserConfigWithKey({ const value = getUserConfigWithKey({
key, key,
userConfig, userConfig,
}); });
// 不校验 false 的值,此时已禁用插件 // 不校验 false 的值,此时已禁用插件
if (value === false) return; if (value === false) {
return;
}
// do validate // do validate
const schema = config.schema(joi); const schema = config.schema(joi);
@ -105,39 +125,28 @@ export default class Config {
return userConfig; return userConfig;
} }
getUserConfig() { async getUserConfig(): Promise<UserConfig> {
const configFile = this.getConfigFile(); const configFile = this.getConfigFile();
this.configFile = configFile; this.configFile = configFile;
if (configFile.length > 0) { if (configFile.length > 0) {
// clear require cache and set babel register const configs = await this.requireConfigs(configFile);
const requireDeps = configFile.reduce((memo, file) => { return this.mergeConfig(configs);
memo = memo.concat(parseRequireDeps(file));
return memo;
}, []);
requireDeps.forEach(cleanRequireCache);
this.service.babelRegister.setOnlyMap({
key: 'config',
value: requireDeps,
});
// require config and merge
return this.mergeConfig(...this.requireConfigs(configFile));
} }
return {}; return {};
} }
addAffix(file, affix) { addAffix(file: string, affix: string): string {
const ext = extname(file); const ext = extname(file);
return file.replace(new RegExp(`${ext}$`), `.${affix}${ext}`); return file.replace(new RegExp(`${ext}$`), `.${affix}${ext}`);
} }
requireConfigs(configFiles) { async requireConfigs(configFiles: string[]): Promise<any[]> {
// eslint-disable-next-line const models = await Promise.all(configFiles.map(f => import(f)));
return configFiles.map((f) => compatESModuleRequire(require(f))); return models.map(m => compatESModuleRequire(m));
} }
mergeConfig(...configs) { mergeConfig(configs: any[]): UserConfig {
let ret = {}; let ret: UserConfig = {};
for (const config of configs) { for (const config of configs) {
// TODO: 精细化处理,比如处理 dotted config key // TODO: 精细化处理,比如处理 dotted config key
ret = deepmerge(ret, config); ret = deepmerge(ret, config);
@ -145,12 +154,14 @@ export default class Config {
return ret; return ret;
} }
getConfigFile() { getConfigFile(): string[] {
// TODO: support custom config file // TODO: support custom config file
let configFile = CONFIG_FILES.find((f) => existsSync(join(this.cwd, f))); let configFile = CONFIG_FILES.find(f => existsSync(join(this.cwd, f)));
if (!configFile) return []; if (!configFile) {
return [];
}
configFile = winPath(configFile); configFile = winPath(configFile);
let envConfigFile; let envConfigFile: string | undefined;
// 潜在问题: // 潜在问题:
// .local 和 .env 的配置必须有 configFile 才有效 // .local 和 .env 的配置必须有 configFile 才有效
if (process.env.FES_ENV) { if (process.env.FES_ENV) {
@ -160,45 +171,46 @@ export default class Config {
} }
} }
const files = [configFile, envConfigFile, this.localConfig && this.addAffix(configFile, 'local')] const files = [configFile, envConfigFile, this.localConfig && this.addAffix(configFile, 'local')]
.filter((f) => !!f) .filter(f => !!f)
.map((f) => join(this.cwd, f)) .map(f => join(this.cwd, f as string))
.filter((f) => existsSync(f)); .filter(f => existsSync(f));
return files; return files as string[];
} }
getWatchFilesAndDirectories() { getWatchFilesAndDirectories(): string[] {
const fesEnv = process.env.FES_ENV; const fesEnv = process.env.FES_ENV;
const configFiles = lodash.clone(CONFIG_FILES); const configFiles = lodash.clone(CONFIG_FILES);
CONFIG_FILES.forEach((f) => { CONFIG_FILES.forEach((f) => {
if (this.localConfig) configFiles.push(this.addAffix(f, 'local')); if (this.localConfig) {
if (fesEnv) configFiles.push(this.addAffix(f, fesEnv)); configFiles.push(this.addAffix(f, 'local'));
}
if (fesEnv) {
configFiles.push(this.addAffix(f, fesEnv as string));
}
}); });
const configDir = winPath(join(this.cwd, 'config')); const configDir = winPath(join(this.cwd, 'config'));
const files = configFiles const files = configFiles
.reduce((memo, f) => { .reduce((memo: string[], f: string) => {
const file = winPath(join(this.cwd, f)); const file = winPath(join(this.cwd, f));
if (existsSync(file)) { memo.push(file);
memo = memo.concat(parseRequireDeps(file));
} else {
memo.push(file);
}
return memo; return memo;
}, []) }, [])
.filter((f) => !f.startsWith(configDir)); .filter(f => !f.startsWith(configDir));
return [configDir].concat(files); return [configDir].concat(files);
} }
watch(opts) { watch(opts: WatchOptions): () => void {
let paths = this.getWatchFilesAndDirectories(); let paths = this.getWatchFilesAndDirectories();
let userConfig = opts.userConfig; let userConfig = opts.userConfig;
const watcher = chokidar.watch(paths, { const watcher = chokidar.watch(paths, {
ignoreInitial: true, ignoreInitial: true,
cwd: this.cwd, cwd: this.cwd,
}); });
watcher.on('all', (event, path) => { watcher.on('all', async (event, path) => {
// eslint-disable-next-line no-console
console.log(chalk.green(`[${event}] ${path}`)); console.log(chalk.green(`[${event}] ${path}`));
const newPaths = this.getWatchFilesAndDirectories(); const newPaths = this.getWatchFilesAndDirectories();
const diffs = lodash.difference(newPaths, paths); const diffs = lodash.difference(newPaths, paths);
@ -207,13 +219,15 @@ export default class Config {
paths = paths.concat(diffs); paths = paths.concat(diffs);
} }
const newUserConfig = this.getUserConfig(); const newUserConfig = await this.getUserConfig();
const pluginChanged = []; const pluginChanged: Array<{ key: string; pluginId: string }> = [];
const valueChanged = []; const valueChanged: Array<{ key: string; pluginId: string }> = [];
Object.keys(this.service.plugins).forEach((pluginId) => { Object.keys(this.service.plugins).forEach((pluginId: string) => {
const { key, config = {} } = this.service.plugins[pluginId]; const { key, config = {} } = this.service.plugins[pluginId];
// recognize as key if have schema config // recognize as key if have schema config
if (!config.schema) return; if (!config.schema) {
return;
}
if (!isEqual(newUserConfig[key], userConfig[key])) { if (!isEqual(newUserConfig[key], userConfig[key])) {
const changed = { const changed = {
key, key,
@ -221,7 +235,8 @@ export default class Config {
}; };
if (newUserConfig[key] === false || userConfig[key] === false) { if (newUserConfig[key] === false || userConfig[key] === false) {
pluginChanged.push(changed); pluginChanged.push(changed);
} else { }
else {
valueChanged.push(changed); valueChanged.push(changed);
} }
} }

View File

@ -0,0 +1,28 @@
import type { UserConfig } from '../../types';
import { lodash } from '@fesjs/utils';
interface UpdateUserConfigWithKeyOptions {
key: string;
value: any;
userConfig: UserConfig;
}
interface GetUserConfigWithKeyOptions {
key: string;
userConfig: UserConfig;
}
export function updateUserConfigWithKey({
key,
value,
userConfig,
}: UpdateUserConfigWithKeyOptions): void {
lodash.set(userConfig, key, value);
}
export function getUserConfigWithKey({
key,
userConfig,
}: GetUserConfigWithKeyOptions): any {
return lodash.get(userConfig, key);
}

View File

@ -0,0 +1,18 @@
import { lodash } from '@fesjs/utils';
function funcToStr(obj: any): any {
if (typeof obj === 'function') {
return obj.toString();
}
if (lodash.isPlainObject(obj)) {
return Object.keys(obj).reduce((memo: Record<string, any>, key: string) => {
memo[key] = funcToStr(obj[key]);
return memo;
}, {});
}
return obj;
}
export default function isEqual(a: any, b: any): boolean {
return lodash.isEqual(funcToStr(a), funcToStr(b));
}

View File

@ -1,8 +1,13 @@
import { deepmerge, lodash } from '@fesjs/utils'; import { deepmerge, lodash } from '@fesjs/utils';
export default ({ defaultConfig, config }) => { interface MergeDefaultOptions {
defaultConfig: any;
config: any;
}
export default function mergeDefault({ defaultConfig, config }: MergeDefaultOptions): any {
if (lodash.isPlainObject(defaultConfig) && lodash.isPlainObject(config)) { if (lodash.isPlainObject(defaultConfig) && lodash.isPlainObject(config)) {
return deepmerge(defaultConfig, config); return deepmerge(defaultConfig, config);
} }
return typeof config !== 'undefined' ? config : defaultConfig; return typeof config !== 'undefined' ? config : defaultConfig;
}; }

View File

@ -0,0 +1,12 @@
import Config from './config';
import Service from './service';
import { PluginType } from './service/enums';
import { isPluginOrPreset } from './service/utils/pluginUtils';
export { Config, isPluginOrPreset, PluginType, Service };
export type {
ConfigInstance,
PluginAPIInstance,
ServiceInstance,
} from './types';

View File

@ -0,0 +1,49 @@
/**
*
*/
export enum PluginType {
preset = 'preset',
plugin = 'plugin',
builder = 'builder',
}
/**
*
*/
export enum ServiceStage {
uninitialized = 0,
constructor = 1,
init = 2,
initPresets = 3,
initPlugins = 4,
initHooks = 5,
pluginReady = 6,
getConfig = 7,
getPaths = 8,
run = 9,
}
/**
*
*/
export enum ConfigChangeType {
reload = 'reload',
regenerateTmpFiles = 'regenerateTmpFiles',
}
/**
*
*/
export enum ApplyPluginsType {
add = 'add',
modify = 'modify',
event = 'event',
}
/**
*
*/
export enum EnableBy {
register = 'register',
config = 'config',
}

View File

@ -0,0 +1,39 @@
import type { Paths, UserConfig } from '../types';
import { existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { lodash, winPath } from '@fesjs/utils';
interface GetServicePathsOptions {
cwd: string;
config: UserConfig;
env: string;
}
function isDirectoryAndExist(path: string): boolean {
return existsSync(path) && statSync(path).isDirectory();
}
function normalizeWithWinPath(obj: Record<string, string>): Record<string, string> {
return lodash.mapValues(obj, value => winPath(value));
}
export default function getServicePaths({ cwd, config, env }: GetServicePathsOptions): Paths {
let absSrcPath = cwd;
if (isDirectoryAndExist(join(cwd, 'src'))) {
absSrcPath = join(cwd, 'src');
}
const absPagesPath = config.singular ? join(absSrcPath, 'page') : join(absSrcPath, 'pages');
const tmpDir = ['.fes', env !== 'development' && env].filter(Boolean).join('-');
const paths = {
tmpDir,
cwd,
absNodeModulesPath: join(cwd, 'node_modules'),
absOutputPath: join(cwd, (config.outputPath as string) || './dist'),
absSrcPath,
absPagesPath,
absTmpPath: join(absSrcPath, tmpDir),
};
return normalizeWithWinPath(paths) as unknown as Paths;
}

View File

@ -1,77 +1,121 @@
/** import type commander from 'commander';
* @copy umi import type {
* https://github.com/umijs/umi/tree/master/packages/core ApplyPluginsOptions,
*/ CommandOption,
import { join } from 'path'; ConfigInstance,
import { EventEmitter } from 'events'; Hook,
import assert from 'assert'; Paths,
import { existsSync } from 'fs'; Plugin,
import { AsyncSeriesWaterfallHook } from 'tapable'; ResolvePluginsOptions,
import { lodash, chalk } from '@fesjs/utils'; ResolvePresetsOptions,
UserConfig,
} from '../types';
import assert from 'node:assert';
import { EventEmitter } from 'node:events';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import process from 'node:process';
import { chalk, lodash } from '@fesjs/utils';
import { Command, Option } from 'commander'; import { Command, Option } from 'commander';
import { readJSONSync } from 'fs-extra/esm';
import { AsyncSeriesWaterfallHook } from 'tapable';
import Config from '../config'; import Config from '../config';
import { getUserConfigWithKey } from '../config/utils/configUtils'; import { getUserConfigWithKey } from '../config/utils/configUtils';
import { resolvePresets, pathToObj, resolvePlugins } from './utils/pluginUtils';
import loadDotEnv from './utils/loadDotEnv';
import isPromise from './utils/isPromise';
import BabelRegister from './babelRegister';
import PluginAPI from './pluginAPI';
import { ApplyPluginsType, ConfigChangeType, EnableBy, PluginType, ServiceStage } from './enums'; import { ApplyPluginsType, ConfigChangeType, EnableBy, PluginType, ServiceStage } from './enums';
import getPaths from './getPaths'; import getPaths from './getPaths';
import PluginAPI from './pluginAPI';
import isPromise from './utils/isPromise';
import loadDotEnv from './utils/loadDotEnv';
import { pathToObj, resolvePlugins, resolvePresets } from './utils/pluginUtils';
interface ServiceOptions {
cwd?: string;
pkg?: Record<string, any>;
env?: string;
fesPkg?: Record<string, any>;
presets?: string[];
plugins?: string[];
}
interface SetupOptions {
presets?: string[];
plugins?: string[];
}
interface ApplyAPIOptions {
apply: () => Promise<any> | any;
api: any;
}
interface InitPresetResult {
presets?: string[];
plugins?: string[];
}
interface ApplyPluginsOptionsExtended extends ApplyPluginsOptions {
args?: any;
}
interface RunOptions {
rawArgv?: Record<string, any>;
args?: Record<string, any>;
}
interface RunCommandOptions {
rawArgv?: Record<string, any>;
args?: Record<string, any>;
}
// TODO // TODO
// 1. duplicated key // 1. duplicated key
export default class Service extends EventEmitter { export default class Service extends EventEmitter {
cwd; cwd: string;
pkg; pkg: Record<string, any>;
skipPluginIds = new Set(); skipPluginIds: Set<string> = new Set();
// lifecycle stage // lifecycle stage
stage = ServiceStage.uninitialized; stage: ServiceStage = ServiceStage.uninitialized;
// registered commands // registered commands
commands = {}; commands: Record<string, CommandOption> = {};
// including plugins // including plugins
plugins = {}; plugins: Record<string, Plugin> = {};
// 构建 // 构建
builder = {}; builder: Record<string, any> = {};
// plugin methods // plugin methods
pluginMethods = {}; pluginMethods: Record<string, () => void> = {};
// initial presets and plugins from arguments, config, process.env, and package.json // initial presets and plugins from arguments, config, process.env, and package.json
initialPresets = []; initialPresets: Plugin[] = [];
// initial plugins from arguments, config, process.env, and package.json // initial plugins from arguments, config, process.env, and package.json
initialPlugins = []; initialPlugins: Plugin[] = [];
_extraPresets = []; _extraPresets: Plugin[] = [];
_extraPlugins = []; _extraPlugins: Plugin[] = [];
// user config // user config
userConfig; userConfig: UserConfig;
configInstance; configInstance: ConfigInstance;
config = null; config: UserConfig | null = null;
// babel register
babelRegister;
// hooks // hooks
hooksByPluginId = {}; hooksByPluginId: Record<string, Hook[]> = {};
hooks = {}; hooks: Record<string, Hook[]> = {};
// paths // paths
paths = {}; paths: Paths;
env; env: string;
ApplyPluginsType = ApplyPluginsType; ApplyPluginsType = ApplyPluginsType;
@ -81,31 +125,35 @@ export default class Service extends EventEmitter {
ServiceStage = ServiceStage; ServiceStage = ServiceStage;
args; args: Record<string, any> | undefined;
constructor(opts) { fesPkg: Record<string, any>;
program: commander.Command;
ready: Promise<boolean>;
constructor(opts: ServiceOptions) {
super(); super();
this.cwd = opts.cwd || process.cwd(); this.cwd = opts.cwd || process.cwd();
// repoDir should be the root dir of repo // repoDir should be the root dir of repo
this.pkg = opts.pkg || this.resolvePackage(); this.pkg = opts.pkg || this.resolvePackage();
this.env = opts.env || process.env.NODE_ENV; this.env = opts.env || process.env.NODE_ENV || 'development';
this.fesPkg = opts.fesPkg || {}; this.fesPkg = opts.fesPkg || {};
this.userConfig = {};
assert(existsSync(this.cwd), `cwd ${this.cwd} does not exist.`); assert(existsSync(this.cwd), `cwd ${this.cwd} does not exist.`);
// register babel before config parsing this.program = this.initCommand();
this.babelRegister = new BabelRegister();
// load .env or .local.env // load .env or .local.env
this.loadEnv(); this.loadEnv();
// get user config without validation
this.configInstance = new Config({ this.configInstance = new Config({
cwd: this.cwd, cwd: this.cwd,
service: this, service: this,
localConfig: this.env === 'development', localConfig: this.env === 'development',
}); });
this.userConfig = this.configInstance.getUserConfig();
// get paths // get paths
this.paths = getPaths({ this.paths = getPaths({
@ -114,41 +162,47 @@ export default class Service extends EventEmitter {
env: this.env, env: this.env,
}); });
this.program = this.initCommand(); this.ready = this.setup(opts);
// setup initial plugins
const baseOpts = {
pkg: this.pkg,
cwd: this.cwd,
};
this.initialPresets = resolvePresets({
...baseOpts,
presets: opts.presets || [],
userConfigPresets: this.userConfig.presets || [],
builder: this.userConfig.builder,
});
this.initialPlugins = resolvePlugins({
...baseOpts,
plugins: opts.plugins || [],
userConfigPlugins: this.userConfig.plugins || [],
builder: this.userConfig.builder,
});
} }
setStage(stage) { async setup(opts: SetupOptions): Promise<boolean> {
// get user config without validation
this.userConfig = await this.configInstance.getUserConfig();
// setup initial plugins
const baseOpts: ResolvePresetsOptions & ResolvePluginsOptions = {
pkg: this.pkg,
cwd: this.cwd,
builder: this.userConfig.builder as string,
};
this.initialPresets = await resolvePresets({
...baseOpts,
presets: opts.presets || [],
userConfigPresets: (this.userConfig.presets as string[]) || [],
});
this.initialPlugins = await resolvePlugins({
...baseOpts,
plugins: opts.plugins || [],
userConfigPlugins: (this.userConfig.plugins as string[]) || [],
});
return true;
}
setStage(stage: ServiceStage): void {
this.stage = stage; this.stage = stage;
} }
resolvePackage() { resolvePackage(): Record<string, any> {
try { try {
// eslint-disable-next-line return readJSONSync(join(this.cwd, 'package.json'));
return require(join(this.cwd, "package.json")); }
} catch (e) { catch {
return {}; return {};
} }
} }
loadEnv() { loadEnv(): void {
const basePath = join(this.cwd, '.env'); const basePath = join(this.cwd, '.env');
const localPath = `${basePath}.local`; const localPath = `${basePath}.local`;
loadDotEnv(basePath); loadDotEnv(basePath);
@ -158,16 +212,16 @@ export default class Service extends EventEmitter {
loadDotEnv(localPath); loadDotEnv(localPath);
} }
async init() { async init(): Promise<void> {
this.setStage(ServiceStage.init); this.setStage(ServiceStage.init);
await this.initPresetsAndPlugins(); await this.initPresetsAndPlugins();
// hooksByPluginId -> hooks // hooksByPluginId -> hooks
// hooks is mapped with hook key, prepared for applyPlugins() // hooks is mapped with hook key, prepared for applyPlugins()
this.setStage(ServiceStage.initHooks); this.setStage(ServiceStage.initHooks);
Object.keys(this.hooksByPluginId).forEach((id) => { Object.keys(this.hooksByPluginId).forEach((id: string) => {
const hooks = this.hooksByPluginId[id]; const hooks = this.hooksByPluginId[id];
hooks.forEach((hook) => { hooks.forEach((hook: Hook) => {
const { key } = hook; const { key } = hook;
hook.pluginId = id; hook.pluginId = id;
this.hooks[key] = (this.hooks[key] || []).concat(hook); this.hooks[key] = (this.hooks[key] || []).concat(hook);
@ -190,51 +244,46 @@ export default class Service extends EventEmitter {
// merge paths to keep the this.paths ref // merge paths to keep the this.paths ref
this.setStage(ServiceStage.getPaths); this.setStage(ServiceStage.getPaths);
// config.outputPath may be modified by plugins // config.outputPath may be modified by plugins
if (this.config.outputPath) { if (this.config?.outputPath) {
this.paths.absOutputPath = join(this.cwd, this.config.outputPath); this.paths.absOutputPath = join(this.cwd, this.config.outputPath as string);
} }
const paths = await this.applyPlugins({ const paths = await this.applyPlugins({
key: 'modifyPaths', key: 'modifyPaths',
type: ApplyPluginsType.modify, type: ApplyPluginsType.modify,
initialValue: this.paths, initialValue: this.paths,
}); }) as Paths;
Object.keys(paths).forEach((key) => { Object.assign(this.paths, paths);
this.paths[key] = paths[key];
});
} }
async setConfig() { async setConfig(): Promise<void> {
const defaultConfig = await this.applyPlugins({ const defaultConfig = await this.applyPlugins({
key: 'modifyDefaultConfig', key: 'modifyDefaultConfig',
type: this.ApplyPluginsType.modify, type: this.ApplyPluginsType.modify,
initialValue: await this.configInstance.getDefaultConfig(), initialValue: await this.configInstance.getDefaultConfig(),
}); }) as Record<string, any>;
const initConfig = await this.configInstance.getConfig(defaultConfig);
this.config = await this.applyPlugins({ this.config = await this.applyPlugins({
key: 'modifyConfig', key: 'modifyConfig',
type: this.ApplyPluginsType.modify, type: this.ApplyPluginsType.modify,
initialValue: this.configInstance.getConfig({ initialValue: initConfig,
defaultConfig,
}),
}); });
} }
async initPresetsAndPlugins() { async initPresetsAndPlugins(): Promise<void> {
this.setStage(ServiceStage.initPresets); this.setStage(ServiceStage.initPresets);
this._extraPlugins = []; this._extraPlugins = [];
while (this.initialPresets.length) { while (this.initialPresets.length) {
// eslint-disable-next-line await this.initPreset(this.initialPresets.shift()!);
await this.initPreset(this.initialPresets.shift());
} }
this.setStage(ServiceStage.initPlugins); this.setStage(ServiceStage.initPlugins);
this._extraPlugins.push(...this.initialPlugins); this._extraPlugins.push(...this.initialPlugins);
while (this._extraPlugins.length) { while (this._extraPlugins.length) {
// eslint-disable-next-line await this.initPlugin(this._extraPlugins.shift()!);
await this.initPlugin(this._extraPlugins.shift());
} }
} }
getPluginAPI(opts) { getPluginAPI(opts: { id: string; key: string; service: Service }): PluginAPI {
const pluginAPI = new PluginAPI(opts); const pluginAPI = new PluginAPI(opts);
// register built-in methods // register built-in methods
@ -246,17 +295,18 @@ export default class Service extends EventEmitter {
}); });
return new Proxy(pluginAPI, { return new Proxy(pluginAPI, {
get: (target, prop) => { get: (target, prop: string) => {
// 由于 pluginMethods 需要在 register 阶段可用 // 由于 pluginMethods 需要在 register 阶段可用
// 必须通过 proxy 的方式动态获取最新,以实现边注册边使用的效果 // 必须通过 proxy 的方式动态获取最新,以实现边注册边使用的效果
if (this.pluginMethods[prop]) return this.pluginMethods[prop]; if (this.pluginMethods[prop]) {
return this.pluginMethods[prop];
}
if ( if (
[ [
'applyPlugins', 'applyPlugins',
'ApplyPluginsType', 'ApplyPluginsType',
'EnableBy', 'EnableBy',
'ConfigChangeType', 'ConfigChangeType',
'babelRegister',
'stage', 'stage',
'ServiceStage', 'ServiceStage',
'paths', 'paths',
@ -273,22 +323,26 @@ export default class Service extends EventEmitter {
'builder', 'builder',
].includes(prop) ].includes(prop)
) { ) {
// @ts-expect-error ignore property
return typeof this[prop] === 'function' ? this[prop].bind(this) : this[prop]; return typeof this[prop] === 'function' ? this[prop].bind(this) : this[prop];
} }
// @ts-expect-error ignore property
return target[prop]; return target[prop];
}, },
}); });
} }
async applyAPI(opts) { async applyAPI(opts: ApplyAPIOptions): Promise<any> {
let ret = opts.apply()(opts.api); const module = await opts.apply();
let ret = module(opts.api);
if (isPromise(ret)) { if (isPromise(ret)) {
ret = await ret; ret = await ret;
} }
return ret || {}; return ret || {};
} }
async initPreset(preset) { async initPreset(preset: Plugin): Promise<void> {
const { id, key, apply } = preset; const { id, key, apply } = preset;
preset.isPreset = true; preset.isPreset = true;
@ -299,22 +353,23 @@ export default class Service extends EventEmitter {
const { presets, plugins } = await this.applyAPI({ const { presets, plugins } = await this.applyAPI({
api, api,
apply, apply,
}); }) as InitPresetResult;
// register extra presets and plugins // register extra presets and plugins
if (presets) { if (presets) {
assert(Array.isArray(presets), `presets returned from preset ${id} must be Array.`); assert(Array.isArray(presets), `presets returned from preset ${id} must be Array.`);
// 插到最前面,下个 while 循环优先执行 // 插到最前面,下个 while 循环优先执行
const _presets = await Promise.all(presets.map(path =>
pathToObj({
type: PluginType.preset,
path,
cwd: this.cwd,
}),
));
this._extraPresets.splice( this._extraPresets.splice(
0, 0,
0, 0,
...presets.map((path) => ..._presets,
pathToObj({
type: PluginType.preset,
path,
cwd: this.cwd,
}),
),
); );
} }
@ -322,25 +377,32 @@ export default class Service extends EventEmitter {
const extraPresets = lodash.clone(this._extraPresets); const extraPresets = lodash.clone(this._extraPresets);
this._extraPresets = []; this._extraPresets = [];
while (extraPresets.length) { while (extraPresets.length) {
// eslint-disable-next-line await this.initPreset(extraPresets.shift()!);
await this.initPreset(extraPresets.shift());
} }
if (plugins) { if (plugins) {
assert(Array.isArray(plugins), `plugins returned from preset ${id} must be Array.`); assert(Array.isArray(plugins), `plugins returned from preset ${id} must be Array.`);
const _plugins = await Promise.all(plugins.map(path =>
pathToObj({
type: PluginType.plugin,
path,
cwd: this.cwd,
}),
));
this._extraPlugins.push( this._extraPlugins.push(
...plugins.map((path) => ..._plugins,
pathToObj({
type: PluginType.plugin,
path,
cwd: this.cwd,
}),
),
); );
} }
// 深度优先
const extraPlugins = lodash.clone(this._extraPlugins);
this._extraPlugins = [];
while (extraPlugins.length) {
await this.initPlugin(extraPlugins.shift()!);
}
} }
async initPlugin(plugin) { async initPlugin(plugin: Plugin): Promise<void> {
const { id, key, apply } = plugin; const { id, key, apply } = plugin;
const api = this.getPluginAPI({ const api = this.getPluginAPI({
@ -357,25 +419,34 @@ export default class Service extends EventEmitter {
}); });
} }
getPluginOptsWithKey(key) { getPluginOptsWithKey(key: string): any {
return getUserConfigWithKey({ return getUserConfigWithKey({
key, key,
userConfig: this.userConfig, userConfig: this.userConfig,
}); });
} }
registerPlugin(plugin) { registerPlugin(plugin: Plugin): void {
this.plugins[plugin.id] = plugin; this.plugins[plugin.id] = plugin;
} }
isPluginEnable(pluginId) { isPluginEnable(pluginId: string): boolean {
// api.skipPlugins() 的插件 // api.skipPlugins() 的插件
if (this.skipPluginIds.has(pluginId)) return false; if (this.skipPluginIds.has(pluginId)) {
return false;
}
const { key, enableBy } = this.plugins[pluginId]; const plugin = this.plugins[pluginId];
if (!plugin) {
return false;
}
const { key, enableBy } = plugin;
// 手动设置为 false // 手动设置为 false
if (this.userConfig[key] === false) return false; if (this.userConfig[key] === false) {
return false;
}
// 配置开启 // 配置开启
if (enableBy === this.EnableBy.config && !(key in this.userConfig)) { if (enableBy === this.EnableBy.config && !(key in this.userConfig)) {
@ -391,77 +462,74 @@ export default class Service extends EventEmitter {
return true; return true;
} }
hasPresets(presetIds) { hasPresets(presetIds: string[]): boolean {
return presetIds.every((presetId) => { return presetIds.every((presetId) => {
const preset = this.plugins[presetId]; const preset = this.plugins[presetId];
return preset && preset.isPreset && this.isPluginEnable(presetId); return preset && preset.isPreset && this.isPluginEnable(presetId);
}); });
} }
hasPlugins(pluginIds) { hasPlugins(pluginIds: string[]): boolean {
return pluginIds.every((pluginId) => { return pluginIds.every((pluginId) => {
const plugin = this.plugins[pluginId]; const plugin = this.plugins[pluginId];
return plugin && !plugin.isPreset && this.isPluginEnable(pluginId); return plugin && !plugin.isPreset && this.isPluginEnable(pluginId);
}); });
} }
async applyPlugins(opts) { async applyPlugins<T>(opts: ApplyPluginsOptionsExtended): Promise<T> {
const hooks = this.hooks[opts.key] || []; const hooks = this.hooks[opts.key] || [];
switch (opts.type) { switch (opts.type) {
case ApplyPluginsType.add: case ApplyPluginsType.add:
if ('initialValue' in opts) { if ('initialValue' in opts) {
assert(Array.isArray(opts.initialValue), 'applyPlugins failed, opts.initialValue must be Array if opts.type is add.'); assert(Array.isArray(opts.initialValue), 'applyPlugins failed, opts.initialValue must be Array if opts.type is add.');
} }
// eslint-disable-next-line // eslint-disable-next-line no-case-declarations
const tAdd = new AsyncSeriesWaterfallHook(["memo"]); const tAdd = new AsyncSeriesWaterfallHook(['memo']);
for (const hook of hooks) { for (const hook of hooks) {
if (!this.isPluginEnable(hook.pluginId)) { if (!this.isPluginEnable(hook.pluginId!)) {
continue; continue;
} }
tAdd.tapPromise( tAdd.tapPromise(
{ {
name: hook.pluginId, name: hook.pluginId!,
stage: hook.stage || 0, stage: hook.stage || 0,
// @ts-ignore
before: hook.before, before: hook.before,
}, },
async (memo) => { async (memo) => {
const items = await hook.fn(opts.args); const items = await hook.fn(opts.args);
return memo.concat(items); return (memo as any[]).concat(items);
}, },
); );
} }
return tAdd.promise(opts.initialValue || []); return tAdd.promise(opts.initialValue || []) as Promise<T>;
case ApplyPluginsType.modify: case ApplyPluginsType.modify:
// eslint-disable-next-line // eslint-disable-next-line no-case-declarations
const tModify = new AsyncSeriesWaterfallHook(["memo"]); const tModify = new AsyncSeriesWaterfallHook(['memo']);
for (const hook of hooks) { for (const hook of hooks) {
if (!this.isPluginEnable(hook.pluginId)) { if (!this.isPluginEnable(hook.pluginId!)) {
continue; continue;
} }
tModify.tapPromise( tModify.tapPromise(
{ {
name: hook.pluginId, name: hook.pluginId!,
stage: hook.stage || 0, stage: hook.stage || 0,
// @ts-ignore
before: hook.before, before: hook.before,
}, },
async (memo) => hook.fn(memo, opts.args), async (memo: any) => hook.fn(memo, opts.args),
); );
} }
return tModify.promise(opts.initialValue); return tModify.promise(opts.initialValue) as Promise<T>;
case ApplyPluginsType.event: case ApplyPluginsType.event:
// eslint-disable-next-line // eslint-disable-next-line no-case-declarations
const tEvent = new AsyncSeriesWaterfallHook(["_"]); const tEvent = new AsyncSeriesWaterfallHook(['_']);
for (const hook of hooks) { for (const hook of hooks) {
if (!this.isPluginEnable(hook.pluginId)) { if (!this.isPluginEnable(hook.pluginId!)) {
continue; continue;
} }
tEvent.tapPromise( tEvent.tapPromise(
{ {
name: hook.pluginId, name: hook.pluginId!,
stage: hook.stage || 0, stage: hook.stage || 0,
// @ts-ignore
before: hook.before, before: hook.before,
}, },
async () => { async () => {
@ -469,22 +537,22 @@ export default class Service extends EventEmitter {
}, },
); );
} }
return tEvent.promise(); return tEvent.promise(true) as Promise<T>;
default: default:
throw new Error(`applyPlugin failed, type is not defined or is not matched, got ${opts.type}.`); throw new Error(`applyPlugin failed, type is not defined or is not matched, got ${(opts as any).type}.`);
} }
} }
initCommand() { initCommand(): commander.Command {
const command = new Command(); const command = new Command();
command command
.usage('<command> [options]') .usage('<command> [options]')
.version(`@fesjs/fes ${this.fesPkg.version}`, '-v, --vers', 'output the current version') .version(`@fesjs/fes ${this.fesPkg.version || ''}`, '-v, --vers', 'output the current version')
.description(chalk.cyan('一个好用的前端应用解决方案')); .description(chalk.cyan('一个好用的前端应用解决方案'));
return command; return command;
} }
async run({ rawArgv = {}, args = {} }) { async run({ rawArgv = {}, args = {} }: RunOptions): Promise<any> {
await this.init(); await this.init();
this.setStage(ServiceStage.run); this.setStage(ServiceStage.run);
@ -499,9 +567,9 @@ export default class Service extends EventEmitter {
return this.runCommand({ rawArgv, args }); return this.runCommand({ rawArgv, args });
} }
async runCommand({ rawArgv = {}, args = {} }) { async runCommand({ rawArgv = {}, args = {} }: RunCommandOptions): Promise<any> {
assert(this.stage >= ServiceStage.init, 'service is not initialized.'); assert(this.stage >= ServiceStage.init, 'service is not initialized.');
Object.keys(this.commands).forEach((command) => { Object.keys(this.commands).forEach((command: string) => {
const commandOptionConfig = this.commands[command]; const commandOptionConfig = this.commands[command];
const program = this.program; const program = this.program;
let c = program.command(command).description(commandOptionConfig.description); let c = program.command(command).description(commandOptionConfig.description);
@ -519,7 +587,7 @@ export default class Service extends EventEmitter {
} }
if (commandOptionConfig.fn) { if (commandOptionConfig.fn) {
c.action(async () => { c.action(async () => {
await commandOptionConfig.fn({ await commandOptionConfig.fn!({
rawArgv, rawArgv,
args, args,
options: c.opts(), options: c.opts(),
@ -532,13 +600,19 @@ export default class Service extends EventEmitter {
return this.parseCommand(); return this.parseCommand();
} }
async parseCommand() { async parseCommand(): Promise<any> {
this.program.on('--help', () => { this.program.on('--help', () => {
// eslint-disable-next-line no-console
console.log(); console.log();
// eslint-disable-next-line no-console
console.log(` Run ${chalk.cyan('fes <command> --help')} for detailed usage of given command.`); console.log(` Run ${chalk.cyan('fes <command> --help')} for detailed usage of given command.`);
// eslint-disable-next-line no-console
console.log(); console.log();
}); });
this.program.commands.forEach((c) => c.on('--help', () => console.log())); this.program.commands.forEach(c => c.on('--help', () => {
// eslint-disable-next-line no-console
console.log();
}));
return this.program.parseAsync(process.argv); return this.program.parseAsync(process.argv);
} }
} }

View File

@ -1,17 +1,36 @@
/** import type {
* @copy umi CommandOption,
* https://github.com/umijs/umi/tree/master/packages/core Hook,
*/ Plugin,
PluginConfig,
import assert from 'assert'; ServiceInstance,
} from '../types';
import assert from 'node:assert';
import * as utils from '@fesjs/utils'; import * as utils from '@fesjs/utils';
import { isValidPlugin, pathToObj } from './utils/pluginUtils';
import { EnableBy, PluginType, ServiceStage } from './enums'; import { EnableBy, PluginType, ServiceStage } from './enums';
import { isValidPlugin, pathToObj } from './utils/pluginUtils';
interface PluginAPIOptions {
id: string;
key: string;
service: ServiceInstance;
}
interface DescribeOptions {
id?: string;
key?: string;
config?: PluginConfig;
enableBy?: EnableBy | (() => boolean);
}
// TODO
// 标准化 logger
export default class PluginAPI { export default class PluginAPI {
constructor(opts) { id: string;
key: string;
service: ServiceInstance;
utils: typeof utils;
logger: typeof utils.logger;
constructor(opts: PluginAPIOptions) {
this.id = opts.id; this.id = opts.id;
this.key = opts.key; this.key = opts.key;
this.service = opts.service; this.service = opts.service;
@ -20,7 +39,7 @@ export default class PluginAPI {
} }
// TODO: reversed keys // TODO: reversed keys
describe({ id, key, config, enableBy } = {}) { describe({ id, key, config, enableBy }: DescribeOptions = {}): void {
const { plugins } = this.service; const { plugins } = this.service;
// this.id and this.key is generated automatically // this.id and this.key is generated automatically
// so we need to diff first // so we need to diff first
@ -46,13 +65,13 @@ export default class PluginAPI {
plugins[this.id].enableBy = enableBy || EnableBy.register; plugins[this.id].enableBy = enableBy || EnableBy.register;
} }
register(hook) { register(hook: Hook): void {
assert(hook.key && typeof hook.key === 'string', `api.register() failed, hook.key must supplied and should be string, but got ${hook.key}.`); assert(hook.key && typeof hook.key === 'string', `api.register() failed, hook.key must supplied and should be string, but got ${hook.key}.`);
assert(hook.fn && typeof hook.fn === 'function', `api.register() failed, hook.fn must supplied and should be function, but got ${hook.fn}.`); assert(hook.fn && typeof hook.fn === 'function', `api.register() failed, hook.fn must supplied and should be function, but got ${hook.fn}.`);
this.service.hooksByPluginId[this.id] = (this.service.hooksByPluginId[this.id] || []).concat(hook); this.service.hooksByPluginId[this.id] = (this.service.hooksByPluginId[this.id] || []).concat(hook);
} }
registerCommand(commandOption) { registerCommand(commandOption: CommandOption): void {
const { command, fn } = commandOption; const { command, fn } = commandOption;
assert(!this.service.commands[command], `api.registerCommand() failed, the command ${command} is exists.`); assert(!this.service.commands[command], `api.registerCommand() failed, the command ${command} is exists.`);
assert(typeof command === 'string', 'api.registerCommand() failed, the command must be string.'); assert(typeof command === 'string', 'api.registerCommand() failed, the command must be string.');
@ -61,67 +80,69 @@ export default class PluginAPI {
} }
// 在 preset 初始化阶段放后面,在插件注册阶段放前面 // 在 preset 初始化阶段放后面,在插件注册阶段放前面
registerPlugins(plugins) { async registerPlugins(plugins: (string | Plugin)[]): Promise<void> {
assert( assert(
this.service.stage === ServiceStage.initPresets || this.service.stage === ServiceStage.initPlugins, this.service.stage === ServiceStage.initPresets || this.service.stage === ServiceStage.initPlugins,
'api.registerPlugins() failed, it should only be used in registering stage.', 'api.registerPlugins() failed, it should only be used in registering stage.',
); );
assert(Array.isArray(plugins), 'api.registerPlugins() failed, plugins must be Array.'); assert(Array.isArray(plugins), 'api.registerPlugins() failed, plugins must be Array.');
const extraPlugins = plugins.map((plugin) => const extraPlugins = await Promise.all(plugins.map(plugin =>
isValidPlugin(plugin) isValidPlugin(plugin)
? plugin ? plugin
: pathToObj({ : pathToObj({
type: PluginType.plugin, type: PluginType.plugin,
path: plugin, path: plugin as string,
cwd: this.service.cwd, cwd: this.service.cwd,
}), })),
); );
if (this.service.stage === ServiceStage.initPresets) { if (this.service.stage === ServiceStage.initPresets) {
this.service._extraPlugins.push(...extraPlugins); this.service._extraPlugins.push(...extraPlugins);
} else { }
else {
this.service._extraPlugins.splice(0, 0, ...extraPlugins); this.service._extraPlugins.splice(0, 0, ...extraPlugins);
} }
} }
registerPresets(presets) { async registerPresets(presets: (string | Plugin)[]): Promise<void> {
assert(this.service.stage === ServiceStage.initPresets, 'api.registerPresets() failed, it should only used in presets.'); assert(this.service.stage === ServiceStage.initPresets, 'api.registerPresets() failed, it should only used in presets.');
assert(Array.isArray(presets), 'api.registerPresets() failed, presets must be Array.'); assert(Array.isArray(presets), 'api.registerPresets() failed, presets must be Array.');
const extraPresets = presets.map((preset) => const extraPresets = await Promise.all(presets.map(preset =>
isValidPlugin(preset) isValidPlugin(preset)
? preset ? preset as Plugin
: pathToObj({ : pathToObj({
type: PluginType.preset, type: PluginType.preset,
path: preset, path: preset as string,
cwd: this.service.cwd, cwd: this.service.cwd,
}), }),
); ));
// 插到最前面,下个 while 循环优先执行 // 插到最前面,下个 while 循环优先执行
this.service._extraPresets.splice(0, 0, ...extraPresets); this.service._extraPresets.splice(0, 0, ...extraPresets);
} }
registerMethod({ name, fn, exitsError = true }) { registerMethod({ name, fn, exitsError = true }: { name: string; fn?: (...args: any[]) => any; exitsError: boolean }): void {
if (this.service.pluginMethods[name]) { if (this.service.pluginMethods[name]) {
if (exitsError) { if (exitsError) {
throw new Error(`api.registerMethod() failed, method ${name} is already exist.`); throw new Error(`api.registerMethod() failed, method ${name} is already exist.`);
} else { }
else {
return; return;
} }
} }
this.service.pluginMethods[name] = this.service.pluginMethods[name]
fn || = fn
// 这里不能用 arrow functionthis 需指向执行此方法的 PluginAPI // 这里不能用 arrow functionthis 需指向执行此方法的 PluginAPI
// 否则 pluginId 会不会,导致不能正确 skip plugin // 否则 pluginId 会不会,导致不能正确 skip plugin
function (hookFn) { || function (hookFn: any) {
const hook = { const hook: Partial<Hook> = {
key: name, key: name,
...(utils.lodash.isPlainObject(hookFn) ? hookFn : { fn: hookFn }), ...(utils.lodash.isPlainObject(hookFn) ? hookFn : { fn: hookFn }),
};
// @ts-expect-error this
this.register(hook as Hook);
}; };
// @ts-ignore
this.register(hook);
};
} }
registerBuilder(builder) { registerBuilder(builder: Record<string, any>): void {
assert(typeof builder === 'object', 'api.registerBuilder() failed, the builder must be object.'); assert(typeof builder === 'object', 'api.registerBuilder() failed, the builder must be object.');
// const { name } = builder; // const { name } = builder;
// assert(typeof name === 'string', 'api.registerBuilder() failed, the builder.name must be string.'); // assert(typeof name === 'string', 'api.registerBuilder() failed, the builder.name must be string.');
@ -129,7 +150,7 @@ export default class PluginAPI {
this.service.builder = builder; this.service.builder = builder;
} }
skipPlugins(pluginIds) { skipPlugins(pluginIds: string[]): void {
pluginIds.forEach((pluginId) => { pluginIds.forEach((pluginId) => {
this.service.skipPluginIds.add(pluginId); this.service.skipPluginIds.add(pluginId);
}); });

View File

@ -1,4 +1,6 @@
export default (api) => { import type { PluginAPIInstance } from '../../types';
export default function builderPlugin(api: PluginAPIInstance): void {
api.describe({ api.describe({
key: 'builder', key: 'builder',
config: { config: {
@ -8,4 +10,4 @@ export default (api) => {
default: '', default: '',
}, },
}); });
}; }

View File

@ -0,0 +1,12 @@
/**
* Promise
* @param obj
* @returns Promise true false
*/
export default function isPromise(obj: any): obj is Promise<any> {
return (
!!obj
&& (typeof obj === 'object' || typeof obj === 'function')
&& typeof obj.then === 'function'
);
}

View File

@ -1,15 +1,15 @@
import { readFileSync, existsSync } from 'fs'; import { existsSync, readFileSync } from 'node:fs';
import process from 'node:process';
import { parse } from 'dotenv'; import { parse } from 'dotenv';
/** /**
* dotenv wrapper * dotenv wrapper
* @param envPath string * @param envPath
*/ */
export default function loadDotEnv(envPath) { export default function loadDotEnv(envPath: string): void {
if (existsSync(envPath)) { if (existsSync(envPath)) {
const parsed = parse(readFileSync(envPath, 'utf-8')) || {}; const parsed = parse(readFileSync(envPath, 'utf-8')) || {};
Object.keys(parsed).forEach((key) => { Object.keys(parsed).forEach((key) => {
// eslint-disable-next-line no-prototype-builtins
process.env[key] = parsed[key]; process.env[key] = parsed[key];
}); });
} }

View File

@ -0,0 +1,214 @@
import type { Plugin } from '../../types';
import { basename, dirname, extname, join, relative } from 'node:path';
import process from 'node:process';
import { chalk, compatESModuleRequire, lodash, resolve, winPath } from '@fesjs/utils';
import { readJSONSync } from 'fs-extra/esm';
import { packageUp } from 'package-up';
import { OWNER_DIR } from '../../shared';
import { PluginType } from '../enums';
interface FilterBuilderOptions {
pkg: Record<string, any>;
builder?: string;
}
interface FilterPluginAndPresetOptions {
pkg: Record<string, any>;
builder?: string;
}
interface GetPluginsOrPresetsOptions {
presets?: string[];
plugins?: string[];
userConfigPresets?: string[];
userConfigPlugins?: string[];
pkg: Record<string, any>;
cwd: string;
builder?: string;
}
interface PathToObjOptions {
path: string;
type: PluginType;
cwd: string;
}
interface ResolvePresetsOptions {
presets?: string[];
userConfigPresets?: string[];
builder?: string;
pkg: Record<string, any>;
cwd: string;
}
interface ResolvePluginsOptions {
plugins?: string[];
userConfigPlugins?: string[];
builder?: string;
pkg: Record<string, any>;
cwd: string;
}
const RE: Record<PluginType, RegExp> = {
[PluginType.plugin]: /^(@fesjs\/|@webank\/fes-|fes-)plugin-(.+)$/,
[PluginType.preset]: /^(@fesjs\/|@webank\/fes-|fes-)preset-(.+)$/,
[PluginType.builder]: /^(@fesjs\/|@webank\/fes-|fes-)builder-(.+)$/,
};
export function isPluginOrPreset(type: PluginType, name: string): boolean {
const hasScope = name.charAt(0) === '@';
const re = RE[type];
if (hasScope) {
return re.test(name.split('/')[1]) || re.test(name);
}
return re.test(name);
}
function filterBuilder(opts: FilterBuilderOptions): string[] {
const builders = Object.keys(opts.pkg.devDependencies || {})
.concat(Object.keys(opts.pkg.dependencies || {}))
.filter(isPluginOrPreset.bind(null, PluginType.builder))
.filter(builder => builder.includes(opts.builder || ''));
if (builders.length > 1) {
// eslint-disable-next-line no-console
console.log(chalk.yellow(`提示您使用了多个builder默认使用第一个${builders[0]}`));
return [builders[0]];
}
return builders;
}
function filterPluginAndPreset(type: PluginType, opts: FilterPluginAndPresetOptions): string[] {
const base = Object.keys(opts.pkg.devDependencies || {})
.concat(Object.keys(opts.pkg.dependencies || {}))
.filter(isPluginOrPreset.bind(null, type));
if (type === PluginType.preset) {
return base.concat(filterBuilder(opts));
}
if (type === PluginType.plugin) {
return base.concat(join(OWNER_DIR, './dist/service/plugins/builder.mjs'));
}
return base;
}
export function getPluginsOrPresets(type: PluginType, opts: GetPluginsOrPresetsOptions): string[] {
const upperCaseType = type.toUpperCase();
return [
// opts
...(opts[type === PluginType.preset ? 'presets' : 'plugins'] || []),
// env
...(process.env[`FES_${upperCaseType}S`] || '').split(',').filter(Boolean),
...filterPluginAndPreset(type, opts),
// user config
...(opts[type === PluginType.preset ? 'userConfigPresets' : 'userConfigPlugins'] || []),
].map(path =>
resolve.sync(path, {
basedir: opts.cwd,
extensions: ['.js', '.ts'],
}),
);
}
// e.g.
// initial-state -> initialState
// webpack.css-loader -> webpack.cssLoader
function nameToKey(name: string): string {
return name
.split('.')
.map(part => lodash.camelCase(part))
.join('.');
}
function pkgNameToKey(pkgName: string, type: PluginType): string {
if (pkgName.charAt(0) === '@' && !pkgName.startsWith('@fesjs/')) {
pkgName = pkgName.split('/')[1];
}
return nameToKey(pkgName.replace(RE[type], ''));
}
export async function pathToObj({ path, type, cwd }: PathToObjOptions): Promise<Plugin> {
let pkg: Record<string, any>;
let isPkgPlugin = false;
const pkgJSONPath = await packageUp({ cwd: path });
if (pkgJSONPath) {
pkg = readJSONSync(pkgJSONPath);
isPkgPlugin = winPath(join(dirname(pkgJSONPath), pkg.main || 'index.js')) === winPath(path);
}
let id: string;
if (isPkgPlugin) {
id = pkg!.name;
}
else if (winPath(path).startsWith(winPath(cwd))) {
id = `./${winPath(relative(cwd, path))}`;
}
else if (pkgJSONPath) {
id = winPath(join(pkg!.name, relative(dirname(pkgJSONPath!), path)));
}
else {
id = winPath(path);
}
id = id.replace('@fesjs/preset-built-in/dist/plugins', '@@');
id = id.replace(/\.js$/, '');
const key = isPkgPlugin ? pkgNameToKey(pkg!.name, type) : nameToKey(basename(path, extname(path)));
return {
id,
key,
path: winPath(path),
async apply() {
try {
const ret = await import(path);
// use the default member for es modules
return compatESModuleRequire(ret);
}
catch (e: any) {
throw new Error(`Register ${path} failed, since ${e.message}`);
}
},
defaultConfig: null,
};
}
export async function resolvePresets(opts: ResolvePresetsOptions): Promise<Plugin[]> {
const type = PluginType.preset;
const presets = await Promise.all([...getPluginsOrPresets(type, opts)].map(path =>
pathToObj({
type,
path,
cwd: opts.cwd,
}),
));
return presets
.sort((a, b) => {
if (a.id === '@fesjs/preset-built-in') {
return -1;
}
if (b.id === '@fesjs/preset-built-in') {
return 1;
}
if (/^(?:@fesjs\/|@webank\/fes-|fes-)builder-/.test(a.id)) {
return -1;
}
if (/^(?:@fesjs\/|@webank\/fes-|fes-)builder-/.test(b.id)) {
return 1;
}
return 0;
});
}
export async function resolvePlugins(opts: ResolvePluginsOptions): Promise<Plugin[]> {
const type = PluginType.plugin;
const plugins = await Promise.all([...getPluginsOrPresets(type, opts)].map(path =>
pathToObj({
type,
path,
cwd: opts.cwd,
}),
));
return plugins;
}
export function isValidPlugin(plugin: any): plugin is Plugin {
return plugin && plugin.id && plugin.key && typeof plugin.apply === 'function';
}

View File

@ -0,0 +1,7 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
*
*/
export const OWNER_DIR: string = join(dirname(fileURLToPath(import.meta.url)), '..');

View File

@ -0,0 +1,94 @@
import type Config from './config';
import type Service from './service';
import type { ApplyPluginsType, EnableBy } from './service/enums';
import type PluginAPI from './service/pluginAPI';
export type ConfigInstance = InstanceType<typeof Config>;
export type ServiceInstance = InstanceType<typeof Service>;
export type PluginAPIInstance = InstanceType<typeof PluginAPI>;
// Enums
export type { ApplyPluginsType, ConfigChangeType, EnableBy, PluginType, ServiceStage } from './service/enums';
// Utility types
export interface UserConfig {
[key: string]: any;
}
export interface Paths {
tmpDir: string;
cwd: string;
absNodeModulesPath: string;
absOutputPath: string;
absSrcPath: string;
absPagesPath: string;
absTmpPath: string;
}
export interface Plugin {
id: string;
key: string;
path: string;
apply: () => Promise<any> | any;
config?: PluginConfig;
enableBy?: EnableBy | (() => boolean);
isPreset?: boolean;
defaultConfig: any;
}
export interface PluginConfig {
schema?: (joi: any) => any;
default?: any;
}
export interface Hook {
key: string;
fn: (...args: any[]) => any;
pluginId?: string;
stage?: number;
before?: string;
}
export interface CommandOption {
command: string;
description: string;
options?: CommandOptionConfig[];
fn?: (args: CommandArgs) => Promise<void> | void;
}
export interface CommandOptionConfig {
name: string;
description: string;
default?: any;
choices?: string[];
}
export interface CommandArgs {
rawArgv: Record<string, any>;
args: Record<string, any>;
options: Record<string, any>;
program: any;
}
export interface ApplyPluginsOptions {
key: string;
type: ApplyPluginsType;
initialValue?: any;
args?: any;
}
export interface ResolvePresetsOptions {
presets?: string[];
userConfigPresets?: string[];
builder?: string;
pkg: Record<string, any>;
cwd: string;
}
export interface ResolvePluginsOptions {
plugins?: string[];
userConfigPlugins?: string[];
builder?: string;
pkg: Record<string, any>;
cwd: string;
}

View File

@ -0,0 +1,9 @@
{
"extends": ["@fesjs/typescript-config/base.json"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./build"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/service/plugins/builder.ts'],
splitting: false,
sourcemap: false,
clean: true,
dts: true,
shims: true,
format: ['esm'],
});

View File

@ -35,16 +35,13 @@ Fes.js 是一个好用的前端应用解决方案。提供覆盖编译构建到
| [@fesjs/plugin-access](http://fesjs.mumblefe.cn/reference/plugin/plugins/access.html) | 提供对页面资源的权限控制能力 | | [@fesjs/plugin-access](http://fesjs.mumblefe.cn/reference/plugin/plugins/access.html) | 提供对页面资源的权限控制能力 |
| [@fesjs/plugin-enums](http://fesjs.mumblefe.cn/reference/plugin/plugins/enums.html#%E4%BB%8B%E7%BB%8D) | 提供统一的枚举存取及丰富的函数来处理枚举 | | [@fesjs/plugin-enums](http://fesjs.mumblefe.cn/reference/plugin/plugins/enums.html#%E4%BB%8B%E7%BB%8D) | 提供统一的枚举存取及丰富的函数来处理枚举 |
| [@fesjs/plugin-icon](http://fesjs.mumblefe.cn/reference/plugin/plugins/icon.html#%E4%BB%8B%E7%BB%8D) | svg 文件自动注册为组件 | | [@fesjs/plugin-icon](http://fesjs.mumblefe.cn/reference/plugin/plugins/icon.html#%E4%BB%8B%E7%BB%8D) | svg 文件自动注册为组件 |
| [@fesjs/plugin-jest](http://fesjs.mumblefe.cn/reference/plugin/plugins/jest.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | 基于 `Jest`,提供单元测试、覆盖测试能力 |
| [ @fesjs/plugin-layout](http://fesjs.mumblefe.cn/reference/plugin/plugins/layout.html) | 简单的配置即可拥有布局,包括导航以及侧边栏 | | [ @fesjs/plugin-layout](http://fesjs.mumblefe.cn/reference/plugin/plugins/layout.html) | 简单的配置即可拥有布局,包括导航以及侧边栏 |
| [@fesjs/plugin-locale](http://fesjs.mumblefe.cn/reference/plugin/plugins/locale.html#%E4%BB%8B%E7%BB%8D) | 基于 `Vue I18n`,提供国际化能力 | | [@fesjs/plugin-locale](http://fesjs.mumblefe.cn/reference/plugin/plugins/locale.html#%E4%BB%8B%E7%BB%8D) | 基于 `Vue I18n`,提供国际化能力 |
| [@fesjs/plugin-model](http://fesjs.mumblefe.cn/reference/plugin/plugins/model.html#%E4%BB%8B%E7%BB%8D) | 简易的数据管理方案 | | [@fesjs/plugin-model](http://fesjs.mumblefe.cn/reference/plugin/plugins/model.html#%E4%BB%8B%E7%BB%8D) | 简易的数据管理方案 |
| [@fesjs/plugin-request](http://fesjs.mumblefe.cn/reference/plugin/plugins/request.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | 基于 `Axios` 封装的 request内置防止重复请求、请求节流、错误处理等功能 | | [@fesjs/plugin-request](http://fesjs.mumblefe.cn/reference/plugin/plugins/request.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | 基于 `Axios` 封装的 request内置防止重复请求、请求节流、错误处理等功能 |
| [@fesjs/plugin-vuex](http://fesjs.mumblefe.cn/reference/plugin/plugins/vuex.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | 基于 `Vuex`, 提供状态管理能力 |
| [@fesjs/plugin-qiankun](http://fesjs.mumblefe.cn/reference/plugin/plugins/qiankun.html#%E4%BB%8B%E7%BB%8D) | 基于 `qiankun`,提供微服务能力 | | [@fesjs/plugin-qiankun](http://fesjs.mumblefe.cn/reference/plugin/plugins/qiankun.html#%E4%BB%8B%E7%BB%8D) | 基于 `qiankun`,提供微服务能力 |
| [@fesjs/plugin-sass](http://fesjs.mumblefe.cn/reference/plugin/plugins/sass.html#%E4%BB%8B%E7%BB%8D) | 样式支持 sass | | [@fesjs/plugin-sass](http://fesjs.mumblefe.cn/reference/plugin/plugins/sass.html#%E4%BB%8B%E7%BB%8D) | 样式支持 sass |
| [@fesjs/plugin-monaco-editor](http://fesjs.mumblefe.cn/reference/plugin/plugins/editor.html#%E4%BB%8B%E7%BB%8D) | 提供代码编辑器能力, 基于`monaco-editor`VS Code 使用的代码编辑器) | | [@fesjs/plugin-monaco-editor](http://fesjs.mumblefe.cn/reference/plugin/plugins/editor.html#%E4%BB%8B%E7%BB%8D) | 提供代码编辑器能力, 基于`monaco-editor`VS Code 使用的代码编辑器) |
| [@fesjs/plugin-windicss](http://fesjs.mumblefe.cn/reference/plugin/plugins/windicss.html) | 基于 `windicss`,提供原子化 CSS 能力 |
| [@fesjs/plugin-pinia](http://fesjs.mumblefe.cn/reference/plugin/plugins/pinia.html) | pinia状态处理 | | [@fesjs/plugin-pinia](http://fesjs.mumblefe.cn/reference/plugin/plugins/pinia.html) | pinia状态处理 |
| [@fesjs/plugin-watermark](http://fesjs.mumblefe.cn/reference/plugin/plugins/watermark.html) | 水印 | | [@fesjs/plugin-watermark](http://fesjs.mumblefe.cn/reference/plugin/plugins/watermark.html) | 水印 |

View File

@ -2,30 +2,30 @@
"name": "@fesjs/create-fes-app", "name": "@fesjs/create-fes-app",
"version": "3.0.7", "version": "3.0.7",
"description": "create a app base on fes.js", "description": "create a app base on fes.js",
"main": "lib/index.js", "author": "qlin",
"files": [ "license": "MIT",
"lib", "homepage": "https://github.com/WeBankFinTech/fes.js#readme",
"bin",
"templates/**/*"
],
"bin": {
"create-fes-app": "bin/create-fes-app.js"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/WeBankFinTech/fes.js.git", "url": "git+https://github.com/WeBankFinTech/fes.js.git",
"directory": "packages/create-fes-app" "directory": "packages/create-fes-app"
}, },
"bugs": {
"url": "https://github.com/WeBankFinTech/fes.js/issues"
},
"keywords": [ "keywords": [
"fes" "fes"
], ],
"sideEffects": false, "sideEffects": false,
"author": "qlin", "main": "lib/index.js",
"license": "MIT", "bin": {
"bugs": { "create-fes-app": "bin/create-fes-app.js"
"url": "https://github.com/WeBankFinTech/fes.js/issues"
}, },
"homepage": "https://github.com/WeBankFinTech/fes.js#readme", "files": [
"bin",
"lib",
"templates/**/*"
],
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },

View File

@ -1,7 +1,7 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import process from 'node:process';
import { chalk, yParser } from '@fesjs/utils'; import { chalk, yParser } from '@fesjs/utils';
import { existsSync } from 'fs';
import { join } from 'path';
const args = yParser(process.argv.slice(2), { const args = yParser(process.argv.slice(2), {
alias: { alias: {
@ -9,9 +9,9 @@ const args = yParser(process.argv.slice(2), {
help: ['h'], help: ['h'],
force: ['f'], force: ['f'],
merge: ['m'], merge: ['m'],
proxy: ['x'] proxy: ['x'],
}, },
boolean: ['version', 'help', 'merge', 'force'] boolean: ['version', 'help', 'merge', 'force'],
}); });
if (args._.length > 1) { if (args._.length > 1) {
@ -25,7 +25,8 @@ if (args.version && !args._[0]) {
: ''; : '';
const { name, version } = require('../package.json'); const { name, version } = require('../package.json');
console.log(`${name}@${version}${local}`); console.log(`${name}@${version}${local}`);
} else if (args.help && !args._[0]) { }
else if (args.help && !args._[0]) {
console.log(` console.log(`
Usage: create-fes-app <name> Usage: create-fes-app <name>
@ -36,11 +37,12 @@ Options:
-m, --merge Merge target directory if it exists -m, --merge Merge target directory if it exists
-x, --proxy <proxyUrl> Use specified proxy when creating project -x, --proxy <proxyUrl> Use specified proxy when creating project
`); `);
} else { }
else {
require('.') require('.')
.default({ .default({
cwd: process.cwd(), cwd: process.cwd(),
args args,
}) })
.catch((err) => { .catch((err) => {
console.error(`Create failed, ${err.message}`); console.error(`Create failed, ${err.message}`);

Some files were not shown because too many files have changed in this diff Show More