Compare commits

..

21 Commits

Author SHA1 Message Date
winixt
6868e85a9b chore: v4.0.0-beta.1 2025-09-22 16:28:08 +08:00
winixt
3a04f3c792 chore: 修复类型导出 2025-09-22 16:27:02 +08:00
winixt
4aeecb773e chore: v4.0.0-beta.0 2025-09-16 11:25:46 +08:00
winixt
30b6b0d7ac chore: 修复 locale 问题 2025-09-11 16:46:04 +08:00
harrywan
42b2f38c2b Merge branch 'feat-esm' of https://github.com/WeBankFinTech/fes.js into feat-esm 2025-09-11 10:25:30 +08:00
harrywan
6db74bfbbc refactor(plugin-model): 使用@vueuse/core实现模型状态共享
重构模型插件,利用@vueuse/core的createSharedComposable实现模型状态共享
移除默认容器模板,改为使用JSX实现的getRootContainer组件
优化路由初始化逻辑,将beforeRender处理移至根容器组件
2025-09-11 10:25:11 +08:00
winixt
0e07b43306 chore: 修复 dev 2025-09-10 14:39:59 +08:00
winixt
c1fa59fcf8 chore: 优化发布流程 2025-09-10 11:39:33 +08:00
winixt
720296fc82 docs: 完善 docs 2025-09-10 10:06:16 +08:00
winixt
4158a1aee2 chore: 兼容fes3.x 的插件 2025-09-10 10:00:00 +08:00
winixt
8297d91004 chore: 修复部分问题 2025-09-10 09:48:01 +08:00
winixt
77b651c50b chore: 修复 watch 问题 2025-09-09 20:43:03 +08:00
winixt
ad860c8f20 chore: 优化 create app 问题 2025-09-09 19:37:24 +08:00
winixt
fd811c5269 chore: 整理 create-app 2025-09-08 18:09:16 +08:00
winixt
f29492ebe0 chore: 修复部分问题 2025-09-08 17:49:05 +08:00
winixt
3c77c9a2b6 chore: 优化部分问题 2025-09-08 17:25:06 +08:00
wanchun
ed26dfb39b fix: 使用 pathToFileURL 确保 Windows 下路径导入正确
修改多处文件导入逻辑,统一使用 pathToFileURL 转换路径为 URL 格式,解决 Windows 系统下路径格式问题导致的模块导入失败
2025-09-07 16:48:28 +08:00
林全昌
2c69c96642 chore: 优化部分代码 2025-09-07 16:29:30 +08:00
林全昌
7c33e3e3ab chore: 构建兼容 windows 2025-09-07 16:27:04 +08:00
林全昌
8448e38c37 chore: 修复部分问题 2025-09-07 14:27:48 +08:00
winixt
c4c081ae3a feat: esm 2025-09-05 22:40:22 +08:00
597 changed files with 19269 additions and 19212 deletions

3
.gitignore vendored
View File

@ -1,7 +1,10 @@
.DS_Store
.idea
.history
.cache
.turbo
.temp
.hound
.fes

3
.npmrc
View File

@ -1,3 +1,4 @@
registry=https://registry.npmmirror.com
auto-install-peers=true
link-workspace-packages=true
prefer-workspace-packages=true
shamefully-hoist=true

View File

@ -1,3 +1,22 @@
# [4.0.0-beta.1](https://github.com/WeBankFinTech/fes.js/compare/v4.0.0-beta.0...v4.0.0-beta.1) (2025-09-22)
# [4.0.0-beta.0](https://github.com/WeBankFinTech/fes.js/compare/v3.4.12...v4.0.0-beta.0) (2025-09-16)
### Bug Fixes
* 优化模版样式 ([effd137](https://github.com/WeBankFinTech/fes.js/commit/effd1378b44dfdfab47ff46ccf1b7e6e0d4d7e66))
* 使用 pathToFileURL 确保 Windows 下路径导入正确 ([ed26dfb](https://github.com/WeBankFinTech/fes.js/commit/ed26dfb39b753f7d28ab1e3d6bd43fb90c972427))
### Features
* esm ([c4c081a](https://github.com/WeBankFinTech/fes.js/commit/c4c081ae3a279ec891236460386b48cf7128d737))
## [3.4.12](https://github.com/WeBankFinTech/fes.js/compare/v3.4.11...v3.4.12) (2025-06-24)

View File

@ -56,12 +56,10 @@ It mainly has the following functions:
| [@fesjs/plugin-access](http://fesjs.mumblefe.cn/reference/plugin/plugins/access.html) | Provides the ability to control the permissions of page resources |
| [@fesjs/plugin-enums](http://fesjs.mumblefe.cn/reference/plugin/plugins/enums.html#%E4%BB%8B%E7%BB%8D) | Provide unified enumeration access and rich functions to handle enumeration |
| [@fesjs/plugin-icon](http://fesjs.mumblefe.cn/reference/plugin/plugins/icon.html#%E4%BB%8B%E7%BB%8D) | svg file is automatically registered as a component |
| [@fesjs/plugin-jest](http://fesjs.mumblefe.cn/reference/plugin/plugins/jest.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | Based on `Jest`, provide unit testing and coverage testing capabilities |
| [@fesjs/plugin-layout](http://fesjs.mumblefe.cn/reference/plugin/plugins/layout.html) | Simple configuration to have a layout, including navigation and sidebar |
| [@fesjs/plugin-locale](http://fesjs.mumblefe.cn/reference/plugin/plugins/locale.html#%E4%BB%8B%E7%BB%8D) | Based on `Vue I18n`, providing internationalization capabilities |
| [@fesjs/plugin-model](http://fesjs.mumblefe.cn/reference/plugin/plugins/model.html#%E4%BB%8B%E7%BB%8D) | Simple data management solution |
| [@fesjs/plugin-request](http://fesjs.mumblefe.cn/reference/plugin/plugins/request.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | Based on the request encapsulated by `Axios`, built-in functions such as preventing repeated requests, request throttling, and error handling |
| [@fesjs/plugin-vuex](http://fesjs.mumblefe.cn/reference/plugin/plugins/vuex.html#%E5%90%AF%E7%94%A8%E6%96%B9%E5%BC%8F) | Based on `Vuex`, provide state management capabilities |
| [@fesjs/plugin-qiankun](http://fesjs.mumblefe.cn/reference/plugin/plugins/qiankun.html#%E4%BB%8B%E7%BB%8D) | Based on `qiankun`, provide microservice capabilities |
| [@fesjs/plugin-sass](http://fesjs.mumblefe.cn/reference/plugin/plugins/sass.html#%E4%BB%8B%E7%BB%8D) | Style support sass |
| [@fesjs/plugin-monaco-editor](http://fesjs.mumblefe.cn/reference/plugin/plugins/editor.html#%E4%BB%8B%E7%BB%8D) | Provide code editor capability, based on `monaco-editor` (code editor used by VS Code) |

View File

@ -1,30 +0,0 @@
export default {
pkgs: [
'create-fes-app',
'fes',
'fes-compiler',
'fes-preset-built-in',
'fes-builder-vite',
'fes-builder-webpack',
'fes-runtime',
'fes-utils',
'fes-plugin-access',
'fes-plugin-enums',
'fes-plugin-icon',
'fes-plugin-jest',
'fes-plugin-layout',
'fes-plugin-locale',
'fes-plugin-model',
'fes-plugin-monaco-editor',
'fes-plugin-qiankun',
'fes-plugin-request',
'fes-plugin-sass',
'fes-plugin-vuex',
'fes-plugin-pinia',
'fes-plugin-windicss',
'fes-plugin-watermark',
'fes-plugin-login',
'fes-plugin-swc',
],
copy: [],
};

18
docs/guide/upgrade4.md Normal file
View File

@ -0,0 +1,18 @@
# 从 3.x 迁移到 4.x
构建模块从 commonjs 切换到 esm
## 需调整内容
- package.json 添加 `"type": "module"`
## 版本 4.x 的 break
1. [@fesjs/builder-vite]: vite5 升级到 [vite7](https://cn.vitejs.dev/guide/migration.html)
2. [@fesjs/plugin-pinia]: pinia 2.x > [3.x](https://github.com/vuejs/pinia/releases/tag/v3.0.0)
## 插件
- 移除插件[@fesjs/plugin-vuex]
- 移除插件[@fesjs/plugin-windicss]
- 移除插件[@fesjs/plugin-jest]

View File

@ -2,11 +2,11 @@
import antfu from '@antfu/eslint-config';
export default await antfu({
// TODO: 使用 ignore 代替 cli 命令中的配置
stylistic: {
indent: 4,
quotes: 'single',
semi: 'always',
ignores: ['*.yaml'],
},
typescript: true,
vue: true,

View File

@ -1,8 +0,0 @@
module.exports = {
testPathIgnorePatterns: [
'/node_modules/',
'fes-template',
'fes-template-h5'
]
};

View File

@ -1,9 +1,9 @@
{
"name": "fes.js",
"type": "module",
"version": "3.4.12",
"version": "4.0.0-beta.1",
"private": true,
"packageManager": "pnpm@8.6.6",
"packageManager": "pnpm@10.14.0",
"description": "一个好用的前端管理台快速开发框架",
"preferGlobal": true,
"workspaces": [
@ -19,8 +19,8 @@
],
"scripts": {
"bootstrap": "pnpm i",
"dev": "node scripts/build.mjs --watch",
"build": "node scripts/build.mjs",
"dev": "turbo run watch",
"build": "turbo run build",
"release": "node scripts/release.mjs",
"clean": "rm -rf ./node_modules & rm -rf packages/**/node_modules & rm -rf packages/**/.cache",
"docs:dev": "vitepress dev docs",
@ -32,29 +32,30 @@
"hooks:sync": "simple-git-hooks"
},
"dependencies": {
"chalk": "^5.0.1",
"conventional-changelog-cli": "^4.1.0",
"enquirer": "^2.3.6",
"consola": "^3.4.2",
"conventional-changelog-cli": "^5.0.0",
"execa": "^6.1.0",
"minimist": "^1.2.6",
"semver": "^7.3.6"
"picocolors": "^1.1.1",
"semver": "^7.3.6",
"tsup": "^8.5.0",
"turbo": "^2.5.6"
},
"devDependencies": {
"@antfu/eslint-config": "^3.8.0",
"@antfu/eslint-config": "^5.2.2",
"@commitlint/cli": "^18.4.4",
"@commitlint/config-conventional": "^18.4.4",
"chokidar": "^3.5.3",
"commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0",
"deepmerge": "^4.2.2",
"eslint": "^9.13.0",
"fs-extra": "^11.1.1",
"eslint": "^9.34.0",
"fs-extra": "^11.3.1",
"lint-staged": "^15.2.0",
"simple-git-hooks": "^2.9.0",
"typescript": "^5.6.3",
"typescript": "^5.9.2",
"vitepress": "1.0.0-alpha.73",
"vue": "^3.3.4",
"yargs-parser": "^21.1.1"
"vue": "^3.5.21"
},
"simple-git-hooks": {
"pre-commit": "npx lint-staged",

View File

@ -1,6 +1,6 @@
{
"name": "@fesjs/builder-vite",
"version": "4.0.5",
"version": "5.0.0-beta.0",
"description": "@fesjs/builder-vite",
"author": "qlin",
"license": "MIT",
@ -8,7 +8,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/WeBankFinTech/fes.js.git",
"directory": "packages/fes-builder-vite"
"directory": "packages/builder-vite"
},
"bugs": {
"url": "https://github.com/WeBankFinTech/fes.js/issues"
@ -16,44 +16,49 @@
"keywords": [
"fes"
],
"main": "lib/index.js",
"main": "dist/index.mjs",
"module": "dist/index.mjs",
"files": [
"lib",
"dist",
"types.d.ts"
],
"scripts": {
"watch": "tsup --watch",
"build": "tsup"
},
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"@fesjs/fes": "^3.1.12",
"core-js": "^3.29.1"
"@fesjs/fes": "^4.0.0-beta.0",
"core-js": "^3.45.1"
},
"dependencies": {
"@babel/core": "^7.23.3",
"@fesjs/utils": "^3.0.3",
"@fesjs/shared": "^4.0.0-beta.0",
"@fesjs/utils": "^4.0.0-beta.0",
"@rollup/pluginutils": "^5.1.0",
"@vitejs/plugin-basic-ssl": "^1.0.2",
"@vitejs/plugin-legacy": "^5.2.0",
"@vitejs/plugin-vue": "^4.5.0",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"autoprefixer": "^10.4.4",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.1.1",
"autoprefixer": "^10.4.21",
"colorette": "^2.0.16",
"connect-history-api-fallback": "^2.0.0",
"consola": "^2.15.3",
"consola": "^3.4.2",
"dotenv": "^16.0.0",
"dotenv-expand": "^8.0.2",
"ejs": "^3.1.6",
"fast-glob": "^3.2.11",
"fs-extra": "^10.0.1",
"html-minifier-terser": "^6.1.0",
"fs-extra": "^11.3.1",
"html-minifier-terser": "^7.2.0",
"less": "^4.2.0",
"node-html-parser": "^5.3.3",
"pathe": "^0.2.0",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-safe-parser": "^6.0.0",
"rollup-plugin-visualizer": "^5.9.3",
"rollup-plugin-visualizer": "^6.0.3",
"terser": "^5.24.0",
"vite": "^5.0.3"
"vite": "^7.1.4"
},
"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 autoprefixer from 'autoprefixer';
import postcssFlexbugsFixes from 'postcss-flexbugs-fixes';
import postcssSafeParser from 'postcss-safe-parser';
import { getInnerCommonConfig } from '../../common/getConfig';
function getEsbuildTarget(targets) {
const result = [];
function getEsbuildTarget(targets: any): string[] {
const result: string[] = [];
['chrome', 'edge', 'firefox', 'hermes', 'ios', 'node', 'opera', 'rhino', 'safari'].forEach((key) => {
if (targets[key]) {
result.push(`${key}${targets[key]}`);
@ -12,20 +17,20 @@ function getEsbuildTarget(targets) {
return result;
}
export default async (api) => {
export default async (api: IPluginAPI<ViteBuildConfig>): Promise<InlineConfig> => {
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 bundleConfig = deepmerge(getInnerCommonConfig(api), {
const bundleConfig: InlineConfig = deepmerge(getInnerCommonConfig(api), {
mode: 'production',
css: {
postcss: {
plugins: [
require('postcss-flexbugs-fixes'),
require('postcss-safe-parser'),
require('autoprefixer')({
postcssFlexbugsFixes,
postcssSafeParser,
autoprefixer({
...api.config.autoprefixer,
overrideBrowserslist: browserslist,
}),
@ -39,12 +44,10 @@ export default async (api) => {
targets,
...api.config.viteLegacy,
}),
splitVendorChunkPlugin(),
],
build: {
...build,
terserOptions: build.terserOptions || api.config.terserOptions,
target: build.target || getEsbuildTarget(targets),
outDir: build.outDir || api.config.outputPath || 'dist',
assetsDir: build.assetsDir || 'static',
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 { getInnerCommonConfig } from '../../common/getConfig';
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 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);
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({
key: 'addBeforeMiddlewares',
@ -25,9 +36,9 @@ export default async (api, 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',
plugins: [viteMiddlewarePlugin(beforeMiddlewares, middlewares), isHTTPS && basicSsl()].filter(Boolean),
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 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 {
paths,
utils: { chalk, rimraf },
} = api;
let server;
let server: ViteDevServer | undefined;
function destroy() {
server?.close();
if (server) {
server.close().catch(() => {});
}
}
api.registerCommand({
@ -26,7 +38,7 @@ export default (api) => {
description: 'whether to turn on the https service',
},
],
async fn({ args = {} }) {
async fn({ args = {} }: Args) {
rimraf.sync(paths.absTmpPath);
await api.applyPlugins({
@ -50,11 +62,14 @@ export default (api) => {
api.registerMethod({
name: 'restartServer',
fn() {
// eslint-disable-next-line no-console
console.log(chalk.gray('Try to restart dev server...'));
destroy();
if (typeof process !== 'undefined' && process.send) {
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',
configureServer(server) {
configureServer(server: any) {
beforeMiddlewares.forEach((middleware) => {
server.middlewares.use(middleware);
});

View File

@ -1,8 +1,10 @@
import type { Plugin } from 'vite';
export default {
name: 'sfc-config',
transform(code, id) {
transform(code: string, id: string) {
if (/vue&type=config/.test(id)) {
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 { existsSync } from 'node:fs';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import { createHtmlPlugin } from './vite-plugin-html';
import SFCConfigBlockPlugin from './SFCConfigBlockPlugin';
import getDefine from './getDefine';
import SFCConfigBlockPlugin from './SFCConfigBlockPlugin';
import { createHtmlPlugin } from './vite-plugin-html';
function getPostcssConfig(api) {
// 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) {
export function getInnerCommonConfig(api: IPluginAPI): InlineConfig {
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 || '/';
@ -27,11 +19,6 @@ export function getInnerCommonConfig(api) {
configFile: false,
define: getDefine(api, publicPath),
cacheDir: join(api.cwd, '.cache'),
css: {
postcss: {
...getPostcssConfig(api),
},
},
plugins: [
vue(api.config.viteVuePlugin || {}),
SFCConfigBlockPlugin,

View File

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

View File

@ -1,34 +1,52 @@
import type { ConfigEnv, Plugin, ResolvedConfig, ViteDevServer } from 'vite';
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 { 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';
function lookupFile(dir, formats, pathOnly = false) {
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);
interface Env {
[key: string]: string;
}
function loadEnv(mode, envDir, prefix = '') {
if (mode === 'local')
throw new Error(`"local" cannot be used as a mode name because it conflicts with the .local postfix for .env files.`);
interface ParsedUrl {
pathname: string;
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`];
for (const file of envFiles) {
const _path = lookupFile(envDir, [file], true);
@ -39,18 +57,20 @@ function loadEnv(mode, envDir, prefix = '') {
ignoreProcessEnv: true,
});
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;
}
else if (key === 'NODE_ENV')
else if (key === 'NODE_ENV') {
process.env.VITE_USER_NODE_ENV = value;
}
}
}
}
return env;
}
async function isDirEmpty(dir) {
async function isDirEmpty(dir: string): Promise<boolean> {
return fse.readdir(dir).then(files => files.length === 0);
}
@ -58,19 +78,44 @@ const DEFAULT_TEMPLATE = 'index.html';
const ignoreDirs = ['.', '', '/'];
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;
let viteConfig;
let env = {};
let viteConfig: ResolvedConfig;
let env: Env = {};
return {
name: 'vite:html',
order: 'pre',
configResolved(resolvedConfig) {
viteConfig = resolvedConfig;
enforce: 'pre',
configResolved(resolvedConfig: ResolvedConfig) {
viteConfig = resolvedConfig!;
if (viteConfig.mode && viteConfig.root) {
env = loadEnv(viteConfig.mode, viteConfig.root, '');
}
},
config(conf) {
const input = createInput(userOptions, conf);
config(config: any, env: ConfigEnv) {
const input = createInput(userOptions, env);
if (input) {
return {
build: {
@ -81,9 +126,9 @@ function createPlugin(userOptions = {}) {
};
}
},
configureServer(server) {
let _pages = [];
const rewrites = [];
configureServer(server: ViteDevServer) {
const _pages: Page[] = [];
const rewrites: Rewrites[] = [];
if (!isMpa(viteConfig)) {
const template2 = userOptions.template || DEFAULT_TEMPLATE;
const filename = DEFAULT_TEMPLATE;
@ -93,24 +138,25 @@ function createPlugin(userOptions = {}) {
});
}
else {
_pages = pages.map(page => ({
_pages.push(...pages.map(page => ({
filename: page.filename || DEFAULT_TEMPLATE,
template: page.template || DEFAULT_TEMPLATE,
}));
})));
}
const proxy = viteConfig.server?.proxy ?? {};
const baseUrl = viteConfig.base ?? '/';
const keys = Object.keys(proxy);
let indexPage = null;
let indexPage: Page | null = null;
for (const page of _pages) {
if (page.filename !== 'index.html')
rewrites.push(createRewire(page.template, page, baseUrl, keys));
else
indexPage = page;
if (page.filename !== 'index.html') {
rewrites.push(createRewire(page.template || '', page, baseUrl, keys));
}
if (indexPage)
else { indexPage = page; }
}
if (indexPage) {
rewrites.push(createRewire('', indexPage, baseUrl, keys));
}
server.middlewares.use(
history(viteConfig, {
@ -122,7 +168,7 @@ function createPlugin(userOptions = {}) {
},
transformIndexHtml: {
order: 'pre',
async handler(html, ctx) {
handler: async (html: string, ctx: any) => {
const url = ctx.filename;
const base = viteConfig.base;
const excludeBaseUrl = url.replace(base, '/');
@ -144,19 +190,23 @@ function createPlugin(userOptions = {}) {
},
},
async closeBundle() {
const outputDirs = [];
const outputDirs: string[] = [];
if (isMpa(viteConfig) || pages.length) {
for (const page of pages) {
if (page.template) {
const dir = path.dirname(page.template);
if (!ignoreDirs.includes(dir))
if (!ignoreDirs.includes(dir)) {
outputDirs.push(dir);
}
}
}
}
else {
const dir = path.dirname(template);
if (!ignoreDirs.includes(dir))
if (!ignoreDirs.includes(dir)) {
outputDirs.push(dir);
}
}
const cwd = path.resolve(viteConfig.root, viteConfig.build.outDir);
const htmlFiles = await fg(
outputDirs.map(dir => `${dir}/*.html`),
@ -176,41 +226,45 @@ function createPlugin(userOptions = {}) {
await Promise.all(
htmlDirs.map(async (item) => {
const isEmpty = await isDirEmpty(item);
if (isEmpty)
if (isEmpty) {
return fse.remove(item);
}
}),
);
},
};
}
function createInput({ pages = [], template = DEFAULT_TEMPLATE }, viteConfig) {
const input = {};
function createInput({ pages = [], template = DEFAULT_TEMPLATE }: UserOptions, viteConfig: any) {
const input: Record<string, string> = {};
if (isMpa(viteConfig) || pages?.length) {
const templates = pages.map(page => page.template);
templates.forEach((temp) => {
templates?.forEach((temp) => {
if (temp) {
let dirName = path.dirname(temp);
const file = path.basename(temp);
dirName = dirName.replace(/\s+/g, '').replace(/\//g, '-');
const key = dirName === '.' || dirName === 'public' || !dirName ? file.replace(/\.html/, '') : dirName;
input[key] = path.resolve(viteConfig.root, temp);
input[key] = path.resolve(viteConfig?.root || '', temp);
}
});
return input;
}
const dir = path.dirname(template);
if (ignoreDirs.includes(dir))
const dir = path.dirname(template || DEFAULT_TEMPLATE);
if (ignoreDirs.includes(dir)) {
return undefined;
}
const file = path.basename(template);
const file = path.basename(template || DEFAULT_TEMPLATE);
const key = file.replace(/\.html/, '');
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 { data, ejsOptions } = injectOptions;
const { data, ejsOptions } = injectOptions || {};
const ejsData = {
...(viteConfig?.env ?? {}),
...(viteConfig?.define ?? {}),
@ -225,43 +279,45 @@ async function renderHtml(html, config) {
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;
if (isMpa(viteConfig) || pages?.length)
if (isMpa(viteConfig) || pages?.length) {
page = getPageConfig(name, pages, DEFAULT_TEMPLATE);
}
else
page = createSpaPage(entry, template, inject);
else { page = createSpaPage(entry, template, inject); }
return page;
}
function isMpa(viteConfig) {
function isMpa(viteConfig: ResolvedConfig | undefined): boolean {
const input = viteConfig?.build?.rollupOptions?.input ?? undefined;
return typeof input !== 'string' && Object.keys(input || {}).length > 1;
}
function removeEntryScript(html, verbose = false) {
if (!html)
function removeEntryScript(html: string, verbose = false): string {
if (!html) {
return html;
}
const root = parse(html);
const scriptNodes = root.querySelectorAll('script[type=module]') || [];
const removedNode = [];
const removedNode: string[] = [];
scriptNodes.forEach((item) => {
removedNode.push(item.toString());
item.parentNode.removeChild(item);
});
verbose
&& removedNode.length
&& consola.warn(`vite-plugin-html: Since you have already configured entry, ${dim(
if (verbose && removedNode.length) {
consola.warn(`vite-plugin-html: Since you have already configured entry, ${dim(
removedNode.toString(),
)} is deleted. You may also delete it from the index.html.
`);
}
return root.toString();
}
function createSpaPage(entry, template, inject = {}) {
function createSpaPage(entry: string | undefined, template: string, inject: InjectOptions = {}): Page {
return {
entry,
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 = {
filename: defaultPage,
template: `./${defaultPage}`,
@ -279,15 +335,16 @@ function getPageConfig(htmlName, pages, defaultPage) {
return page ?? defaultPageOption ?? undefined;
}
function createRewire(reg, page, baseUrl, proxyUrlKeys) {
function createRewire(reg: string, page: Page, baseUrl: string, proxyUrlKeys: string[]): Rewrites {
return {
from: new RegExp(`^/${reg}*`),
to({ parsedUrl }) {
to({ parsedUrl }: { parsedUrl: ParsedUrl }) {
const pathname = parsedUrl.pathname;
const excludeBaseUrl = pathname.replace(baseUrl, '/');
const template = path.resolve(baseUrl, page.template);
if (excludeBaseUrl === '/')
const template = path.resolve(baseUrl, page.template || '');
if (excludeBaseUrl === '/') {
return template;
}
const isApiUrl = proxyUrlKeys.some(item => pathname.startsWith(path.resolve(baseUrl, item)));
return isApiUrl ? parsedUrl.path : template;
@ -297,7 +354,7 @@ function createRewire(reg, page, baseUrl, proxyUrlKeys) {
const htmlFilter = createFilter(['**/*.html']);
function getOptions(_minify) {
function getOptions(_minify: boolean) {
return {
collapseWhitespace: _minify,
keepClosingSlash: _minify,
@ -310,37 +367,41 @@ function getOptions(_minify) {
};
}
async function minifyHtml(html, minify$1) {
if (typeof minify$1 === 'boolean' && !minify$1)
async function minifyHtml(html: string, minifyOptions: boolean | any): Promise<string> {
if (typeof minifyOptions === 'boolean' && !minifyOptions) {
return html;
}
let minifyOptions = minify$1;
if (typeof minify$1 === 'boolean' && minify$1)
minifyOptions = getOptions(minify$1);
let minifyConfig = minifyOptions;
if (typeof minifyOptions === 'boolean' && minifyOptions) {
minifyConfig = getOptions(minifyOptions);
}
const res = await minify(html, minifyOptions);
const res = await minify(html, minifyConfig);
return res;
}
function createMinifyHtmlPlugin({ _minify = true } = {}) {
function createMinifyHtmlPlugin({ _minify = true } = {}): Plugin {
return {
name: 'vite:minify-html',
order: 'post',
apply: 'build',
async generateBundle(_, outBundle) {
if (_minify) {
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);
}
}
}
},
};
}
consola.wrapConsole();
function createHtmlPlugin(userOptions = {}) {
function createHtmlPlugin(userOptions: UserOptions = {}): Plugin[] {
return [createPlugin(userOptions), createMinifyHtmlPlugin(userOptions)];
}
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({
key: 'viteAnalyze',
config: {
schema(joi) {
schema(joi: any) {
return joi.object();
},
default: {},
@ -10,14 +14,14 @@ export default (api) => {
enableBy: () => !!process.env.ANALYZE,
});
api.modifyBundleConfig((memo) => {
api.modifyBundleConfig((memo: any) => {
memo.plugins.push(
require('rollup-plugin-visualizer').visualizer({
visualizer({
filename: './.cache/visualizer/stats.html',
open: true,
gzipSize: 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({
key: 'viteHtml',
config: {
schema(joi) {
schema(joi: any) {
return joi.object();
},
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({
key: 'viteLegacy',
config: {
schema(joi) {
schema(joi: any) {
return joi.object();
},
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({
key: 'viteVueJsx',
config: {
schema(joi) {
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({
key: 'viteVuePlugin',
config: {
schema(joi) {
schema(joi: any) {
return joi.object();
},
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) => {
api.registerMethod({ name });
});

View File

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

@ -1,6 +1,6 @@
{
"name": "@fesjs/builder-webpack",
"version": "3.1.0",
"version": "4.0.0-beta.0",
"description": "@fesjs/builder-webpack",
"author": "qlin",
"license": "MIT",
@ -8,7 +8,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/WeBankFinTech/fes.js.git",
"directory": "packages/fes-builder-webpack"
"directory": "packages/builder-webpack"
},
"bugs": {
"url": "https://github.com/WeBankFinTech/fes.js/issues"
@ -16,46 +16,51 @@
"keywords": [
"fes"
],
"main": "lib/index.js",
"types": "types.d.ts",
"main": "dist/index.mjs",
"module": "dist/index.mjs",
"files": [
"lib",
"dist",
"types.d.ts"
],
"scripts": {
"watch": "tsup --watch",
"build": "tsup"
},
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"@fesjs/fes": "^3.1.17",
"core-js": "^3.29.1"
"@fesjs/fes": "^4.0.0-beta.0",
"core-js": "^3.45.1"
},
"dependencies": {
"@babel/core": "^7.23.2",
"@babel/plugin-proposal-do-expressions": "^7.22.5",
"@babel/plugin-proposal-export-default-from": "^7.22.17",
"@babel/plugin-proposal-function-bind": "^7.22.5",
"@babel/plugin-proposal-pipeline-operator": "^7.22.15",
"@babel/plugin-transform-runtime": "^7.23.2",
"@babel/preset-env": "^7.23.2",
"@babel/preset-typescript": "^7.23.2",
"@fesjs/utils": "^3.0.3",
"@vue/babel-plugin-jsx": "^1.2.2",
"@babel/core": "^7.28.3",
"@babel/plugin-proposal-do-expressions": "^7.28.3",
"@babel/plugin-proposal-export-default-from": "^7.27.1",
"@babel/plugin-proposal-function-bind": "^7.27.1",
"@babel/plugin-proposal-pipeline-operator": "^7.27.1",
"@babel/plugin-transform-runtime": "^7.28.3",
"@babel/preset-env": "^7.28.3",
"@babel/preset-typescript": "^7.27.1",
"@fesjs/shared": "^4.0.0-beta.0",
"@fesjs/utils": "^4.0.0-beta.0",
"@vue/babel-plugin-jsx": "^1.5.0",
"ajv": "^8.12.0",
"autoprefixer": "^10.4.14",
"babel-loader": "^9.1.2",
"babel-loader": "^10.0.0",
"cli-highlight": "^2.1.11",
"cliui": "^8.0.1",
"connect-history-api-fallback": "^2.0.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^5.0.0",
"fs-extra": "^11.1.1",
"get-folder-size": "^2.0.1",
"fs-extra": "^11.3.1",
"get-folder-size": "^5.0.0",
"html-webpack-plugin": "^5.5.0",
"html-webpack-tags-plugin": "^3.0.2",
"less": "^4.1.3",
"less-loader": "^11.1.0",
"mini-css-extract-plugin": "^2.8.1",
"mini-css-extract-plugin": "^2.9.4",
"postcss": "^8.4.33",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-loader": "^7.1.0",
@ -63,8 +68,8 @@
"style-loader": "^3.3.2",
"terser-webpack-plugin": "^5.3.6",
"vue-loader": "^17.4.2",
"webpack": "^5.90.3",
"webpack-5-chain": "^8.0.1",
"webpack": "^5.101.3",
"webpack-5-chain": "^8.0.2",
"webpack-bundle-analyzer": "^4.4.0",
"webpack-dev-server": "^5.1.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';
export async function build({ bundleConfig }) {
export async function build(bundleConfig: webpack.Configuration): Promise<{ stats?: webpack.Stats }> {
return new Promise((resolve, reject) => {
const compiler = webpack(bundleConfig);
compiler.run((err, stats) => {
compiler!.run((err, stats) => {
if (err) {
console.error(err);
return reject(new Error('build failed'));

View File

@ -1,13 +1,12 @@
/**
* @copy umi
* https://github.com/umijs/umi/blob/master/packages/preset-built-in/src/plugins/commands/build/build.ts
*/
import { relative } from 'path';
import { existsSync } from 'fs';
import type { IPluginAPI } from '@fesjs/shared';
import type { WebpackBuildConfig } from '../../../shared';
import { existsSync } from 'node:fs';
import { relative } from 'node:path';
import process from 'node:process';
import { cleanTmpPathExceptCache, getBundleAndConfigs, printFileSizes } from '../../common/buildDevUtils';
import { build } from './build';
export default function (api) {
export default function (api: IPluginAPI<WebpackBuildConfig>) {
const {
paths,
utils: { rimraf, logger },
@ -17,8 +16,6 @@ export default function (api) {
command: 'build',
description: 'build application for production',
async fn() {
const { build } = require('./build');
cleanTmpPathExceptCache({
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') {
rimraf.sync(paths.absTmpPath);
}
printFileSizes(stats, relative(process.cwd(), paths.absOutputPath));
if (stats) {
printFileSizes({ stats, dir: relative(process.cwd(), paths.absOutputPath) });
}
await api.applyPlugins({
key: 'onBuildComplete',
type: api.ApplyPluginsType.event,
@ -52,7 +51,8 @@ export default function (api) {
stats,
},
});
} catch (err) {
}
catch (err) {
await api.applyPlugins({
key: 'onBuildComplete',
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 historyFallback from 'connect-history-api-fallback';
const ASSET_EXT_NAMES = ['.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg'];
function proxyMiddleware(api) {
return (req, res, next) => {
function proxyMiddleware(api: IPluginAPI<WebpackBuildConfig>) {
return (req: any, res: any, next: any) => {
const proxyConfig = api.config.proxy;
if (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();
}
}

View File

@ -1,8 +1,20 @@
import type { WebpackBuildConfig } from '../../../shared';
import { chalk } from '@fesjs/utils';
import webpack from 'webpack';
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) {
return [];
}
@ -19,8 +31,11 @@ function formatProxy(proxy) {
});
}
export function startDevServer({ webpackConfig, host, port, proxy, https, beforeMiddlewares, afterMiddlewares, customerDevServerConfig }) {
const options = {
export function startDevServer({ webpackConfig, host, port, proxy, https, beforeMiddlewares, afterMiddlewares, customerDevServerConfig }: StartDevServerOptions) {
const headers: Record<string, string> = {
'access-control-allow-origin': '*',
};
const options: WebpackDevServer.Configuration = {
hot: true,
allowedHosts: 'all',
server: https ? 'https' : 'http',
@ -39,15 +54,17 @@ export function startDevServer({ webpackConfig, host, port, proxy, https, before
return middlewares;
},
headers: {
'access-control-allow-origin': '*',
},
// @ts-expect-error 不知道这里为啥异常
headers,
...(customerDevServerConfig || {}),
port,
host,
proxy: formatProxy(proxy),
};
const compiler = webpack(webpackConfig);
if (!compiler) {
throw new Error('Failed to create webpack compiler');
}
const server = new WebpackDevServer(options, compiler);
if (options.host === '0.0.0.0') {
// 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 path from 'node:path';
import process from 'node:process';
import { removeSync } from 'fs-extra/esm';
import getFolderSize from 'get-folder-size';
import { cleanTmpPathExceptCache, getBundleAndConfigs } from '../../common/buildDevUtils';
import connectHistoryMiddleware from './connectHistoryMiddleware';
import { startDevServer } from './devServer';
async function handleCacheClean(cwd) {
return new Promise((resolve, reject) => {
async function handleCacheClean(cwd: string) {
return new Promise((resolve) => {
const cachePath = path.join(cwd, '.cache/webpack');
if (!fs.existsSync(cachePath)) {
return resolve();
}
require('get-folder-size')(cachePath, (err, size) => {
if (err) {
return reject(err);
return resolve(0);
}
// 大于 5G 清除缓存,修复 webpack 缓存无限增长问题
// https://github.com/webpack/webpack/issues/13291
getFolderSize.loose(cachePath).then((size) => {
if (size > 5 * 1024 * 1024 * 1024) {
require('fs-extra').removeSync(cachePath);
removeSync(cachePath);
}
resolve(size);
});
});
}
export default (api) => {
export default (api: IPluginAPI<WebpackBuildConfig>) => {
const {
paths,
utils: { chalk, getPort, getHostName, changePort, logger },
} = api;
let port;
let hostname;
let server;
let port: number;
let hostname: string;
let server: any;
async function destroy() {
await server?.stop();
@ -90,7 +92,6 @@ export default (api) => {
args: {},
});
const { startDevServer } = require('./devServer');
server = startDevServer({
webpackConfig: bundleConfig,
host: hostname,
@ -113,7 +114,7 @@ export default (api) => {
fn() {
logger.info(chalk.gray('Try to restart dev server...'));
destroy();
process.send({
process.send?.({
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';
export default function (api) {
export default function (api: IPluginAPI<WebpackBuildConfig>) {
api.registerCommand({
command: 'webpack',
description: 'inspect webpack configurations',
@ -26,26 +30,27 @@ export default function (api) {
description: 'show full function definitions in output',
},
],
async fn({ options }) {
const assert = require('assert');
const { toString } = require('webpack-5-chain');
const { highlight } = require('cli-highlight');
async fn({ options }: { options: any }) {
const { bundleConfig } = await getBundleAndConfigs({ api });
let config = bundleConfig;
let config: any = bundleConfig;
assert(config, 'No valid config found with fes entry.');
if (options.rule) {
config = config.module.rules.find((r) => r.__ruleNames[0] === options.rule);
} else if (options.plugin) {
config = config.plugins.find((p) => p.__pluginName === options.plugin);
} else if (options.rules) {
config = config.module.rules.map((r) => r.__ruleNames[0]);
} else if (options.plugins) {
config = config.plugins.map((p) => p.__pluginName || p.constructor.name);
config = config.module.rules.find((r: any) => r.__ruleNames[0] === options.rule);
}
else if (options.plugin) {
config = config.plugins.find((p: any) => p.__pluginName === options.plugin);
}
else if (options.rules) {
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 @@
/**
* @copy umi
* https://github.com/umijs/umi/blob/master/packages/preset-built-in/src/plugins/commands/buildDevUtils.ts
*/
import { join, resolve } from 'path';
import { existsSync, readFileSync } from 'fs';
import zlib from 'zlib';
import { rimraf, chalk } from '@fesjs/utils';
import type { IPluginAPI } from '@fesjs/shared';
import type webpack from 'webpack';
import type { WebpackBuildConfig } from '../../shared';
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import zlib from 'node:zlib';
import { chalk, rimraf } from '@fesjs/utils';
import UI from 'cliui';
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
const env = api.env === 'production' ? 'production' : 'development';
const getConfigOpts = await api.applyPlugins({
const getConfigOpts: any = await api.applyPlugins({
type: api.ApplyPluginsType.modify,
key: 'modifyBundleConfigOpts',
initialValue: {
@ -22,22 +29,21 @@ export async function getBundleAndConfigs({ api }) {
entry: {
index: join(api.paths.absTmpPath, 'fes.js'),
},
// @ts-ignore
async modifyBabelOpts(opts) {
async modifyBabelOpts(opts: any) {
return api.applyPlugins({
type: api.ApplyPluginsType.modify,
key: 'modifyBabelOpts',
initialValue: opts,
});
},
async modifyBabelPresetOpts(opts) {
async modifyBabelPresetOpts(opts: any) {
return api.applyPlugins({
type: api.ApplyPluginsType.modify,
key: 'modifyBabelPresetOpts',
initialValue: opts,
});
},
async chainWebpack(webpackConfig, opts) {
async chainWebpack(webpackConfig: any, opts: any) {
return api.applyPlugins({
type: api.ApplyPluginsType.modify,
key: 'chainWebpack',
@ -64,7 +70,7 @@ export async function getBundleAndConfigs({ api }) {
args: {},
});
const bundleConfig = await api.applyPlugins({
const bundleConfig: webpack.Configuration = await api.applyPlugins({
type: api.ApplyPluginsType.modify,
key: 'modifyBundleConfig',
initialValue: await getConfig({ api, ...getConfigOpts }),
@ -74,7 +80,11 @@ export async function getBundleAndConfigs({ api }) {
return { bundleConfig };
}
export function cleanTmpPathExceptCache({ absTmpPath }) {
interface CleanTmpPathExceptCacheOptions {
absTmpPath: string;
}
export function cleanTmpPathExceptCache({ absTmpPath }: CleanTmpPathExceptCacheOptions) {
rimraf.sync(absTmpPath);
}
@ -82,15 +92,20 @@ export function cleanTmpPathExceptCache({ absTmpPath }) {
const WARN_AFTER_BUNDLE_GZIP_SIZE = 1.8 * 1024 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1 * 1024 * 1024;
export function printFileSizes(stats, dir) {
const ui = require('cliui')({ width: 80 });
const json = stats.toJson({
interface PrintFileSizesOptions {
stats: webpack.Stats;
dir: string;
}
export function printFileSizes({ stats, dir }: PrintFileSizesOptions) {
const ui = UI({ width: 80 });
const json: any = stats.toJson({
hash: false,
modules: false,
chunks: false,
});
const filesize = (bytes) => {
const filesize = (bytes: number) => {
bytes = Math.abs(bytes);
const radix = 1024;
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]}`;
};
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 isJS = (val) => /\.js$/.test(val);
const isCSS = (val) => /\.css$/.test(val);
const isJS = (val: string) => /\.js$/.test(val);
const isCSS = (val: string) => /\.css$/.test(val);
const orderedAssets = assets
.map((a) => {
const orderedAssets: any[] = assets
.map((a: any) => {
a.name = a.name.split('?')[0];
// These sizes are pretty large
const isMainBundle = a.name.indexOf('fes.') === 0;
@ -122,20 +137,24 @@ export function printFileSizes(stats, dir) {
suggested: isLarge && isJS(a.name),
};
})
.filter((a) => {
.filter((a: any) => {
if (seenNames.has(a.name)) {
return false;
}
seenNames.set(a.name, true);
return isJS(a.name) || isCSS(a.name);
})
.sort((a, b) => {
if (isJS(a.name) && isCSS(b.name)) return -1;
if (isCSS(a.name) && isJS(b.name)) return 1;
.sort((a: any, b: any) => {
if (isJS(a.name) && isCSS(b.name)) {
return -1;
}
if (isCSS(a.name) && isJS(b.name)) {
return 1;
}
return b.size - a.size;
});
function getGzippedSize(asset) {
function getGzippedSize(asset: any) {
const filepath = resolve(join(dir, asset.name));
if (existsSync(filepath)) {
const buffer = readFileSync(filepath);
@ -144,15 +163,15 @@ export function printFileSizes(stats, dir) {
return filesize(0);
}
function makeRow(a, b, c) {
function makeRow(a: string, b: string, c: string) {
return ` ${a}\t ${b}\t ${c}`;
}
ui.div(
`${makeRow(chalk.cyan.bold('File'), chalk.cyan.bold('Size'), chalk.cyan.bold('Gzipped'))}\n\n${orderedAssets
.map((asset) =>
.map((asset: any) =>
makeRow(
/js$/.test(asset.name)
asset.name.endsWith('js')
? asset.suggested
? chalk.yellow(join(dir, asset.name))
: chalk.green(join(dir, asset.name))
@ -164,13 +183,19 @@ export function printFileSizes(stats, dir) {
.join('\n')}`,
);
// eslint-disable-next-line no-console
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();
// eslint-disable-next-line no-console
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'));
// eslint-disable-next-line no-console
console.log(chalk.yellow('You can also analyze the project dependencies using ANALYZE=1'));
// eslint-disable-next-line no-console
console.log();
}
}

View File

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

View File

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

View File

@ -1,14 +1,28 @@
import type { WebpackBuildConfig } from '../../../shared';
import process from 'node:process';
import { winPath } from '@fesjs/utils';
import { esmRequire, esmResolve } from '../../../shared';
function getBabelOpts({ cwd, targets, config, presetOpts }) {
const presets = [
interface PresetOpts {
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,
useBuiltIns: 'usage',
corejs: {
version: require('core-js/package.json').version,
version: esmRequire('core-js/package.json').version,
proposals: true,
},
modules: false,
@ -16,7 +30,7 @@ function getBabelOpts({ cwd, targets, config, presetOpts }) {
],
[
// FEATURE 实现类型安全检查
require('@babel/preset-typescript').default,
esmRequire('@babel/preset-typescript').default,
{
// https://babeljs.io/docs/en/babel-plugin-transform-typescript#impartial-namespace-support
allowNamespaces: true,
@ -26,24 +40,24 @@ function getBabelOpts({ cwd, targets, config, presetOpts }) {
],
...(config.extraBabelPresets || []),
];
const plugins = [
require('@babel/plugin-proposal-export-default-from').default,
const plugins: any[] = [
esmRequire('@babel/plugin-proposal-export-default-from').default,
[
require('@babel/plugin-proposal-pipeline-operator').default,
esmRequire('@babel/plugin-proposal-pipeline-operator').default,
{
proposal: 'minimal',
},
],
require('@babel/plugin-proposal-do-expressions').default,
require('@babel/plugin-proposal-function-bind').default,
esmRequire('@babel/plugin-proposal-do-expressions').default,
esmRequire('@babel/plugin-proposal-function-bind').default,
[
require.resolve('@babel/plugin-transform-runtime'),
esmResolve('@babel/plugin-transform-runtime'),
{
useESModules: true,
...presetOpts.transformRuntime,
},
],
require.resolve('@vue/babel-plugin-jsx'),
esmResolve('@vue/babel-plugin-jsx'),
...(config.extraBabelPlugins || []),
];
return {
@ -61,8 +75,16 @@ function getBabelOpts({ cwd, targets, config, presetOpts }) {
};
}
export default async ({ cwd, config, modifyBabelOpts, modifyBabelPresetOpts, targets }) => {
let presetOpts = {
interface ExportDefaultOptions {
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: {},
};
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 { platform } from 'node:os';
import { join } from 'node:path';
import webpack from 'webpack';
import Config from 'webpack-5-chain';
import pkg from '../../../../package.json' assert { type: 'json' };
import { esmResolve } from '../../../shared';
import createCssWebpackConfig from './css';
import createDefineWebpackConfig from './define';
import getBabelOpts from './getBabelOpts';
@ -22,32 +27,54 @@ const DEFAULT_EXCLUDE_NODE_MODULES = [
'html-entities',
];
function genTranspileDepRegex(exclude) {
function genTranspileDepRegex(exclude: (string | RegExp)[]) {
exclude = exclude.concat(DEFAULT_EXCLUDE_NODE_MODULES);
const deps = exclude.map((dep) => {
if (typeof dep === 'string') {
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');
});
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;
if (config.alias) {
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.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 isProd = env === 'production';
const webpackConfig = new Config();
@ -56,12 +83,15 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
webpackConfig.mode(env);
webpackConfig.stats('errors-only');
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 -----------
webpackConfig.cache({
type: 'filesystem',
version: require('../../../../package.json').version,
version: pkg.version,
cacheDirectory: join(cwd, '.cache/webpack'),
});
@ -74,7 +104,7 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
// --------------- output -----------
webpackConfig.output
.path(absoluteOutput)
.publicPath(publicPath)
.publicPath(publicPath || '')
.filename('static/[name].[contenthash:8].js')
.chunkFilename('static/[name].[contenthash:8].chunk.js')
.assetModuleFilename('static/[name][hash:8][ext]');
@ -131,21 +161,23 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
.rule('js')
.test(/\.(js|mjs|jsx|ts|tsx)$/)
.exclude
.add((filepath) => {
.add((filepath: string) => {
// 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
return /node_modules/.test(filepath);
})
.end()
.use('babel-loader')
.loader(require.resolve('babel-loader'))
.loader(esmResolve('babel-loader'))
.options(babelOpts);
// 为了避免第三方依赖包编译不充分导致线上问题,默认对 node_modules 也进行全编译,只在生产构建的时候进行
if (isProd) {
const transpileDepRegex = genTranspileDepRegex(config.nodeModulesTransform.exclude);
const transpileDepRegex = genTranspileDepRegex(config.nodeModulesTransform?.exclude || []);
webpackConfig.module
.rule('js-in-node_modules')
.test(/\.(js|mjs)$/)
@ -153,14 +185,16 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
.add(/node_modules/)
.end()
.exclude
.add((filepath) => {
if (transpileDepRegex && transpileDepRegex.test(filepath)) { return true; }
.add((filepath: string) => {
if (transpileDepRegex && transpileDepRegex.test(filepath)) {
return true;
}
return false;
})
.end()
.use('babel-loader')
.loader(require.resolve('babel-loader'))
.loader(esmResolve('babel-loader'))
.options(babelOpts);
}
@ -190,22 +224,27 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
});
// --------------- copy -----------
const copyFiles = Array.isArray(config.copy) ? config.copy : config.copy ? [config.copy] : [];
const copyPatterns = [
existsSync(join(cwd, 'public')) && {
from: join(cwd, 'public'),
filter: (resourcePath) => {
if (resourcePath.includes('.DS_Store')) { return false; }
filter: (resourcePath: string) => {
if (resourcePath.includes('.DS_Store')) {
return false;
}
if (publicCopyIgnore.includes(resourcePath)) { return false; }
if (publicCopyIgnore.includes(resourcePath)) {
return false;
}
return true;
},
to: absoluteOutput,
},
...(config.copy || []).map((item) => {
...copyFiles.map((item: any) => {
if (typeof item === 'string') {
return {
from: join(cwd, item.from),
from: join(cwd, item),
to: absoluteOutput,
};
}
@ -217,14 +256,14 @@ export default async function getConfig({ api, cwd, config, env, entry = {}, mod
].filter(Boolean);
// const publicCopyIgnore = ['.DS_Store'];
if (copyPatterns.length) {
webpackConfig.plugin('copy').use(require.resolve('copy-webpack-plugin'), [
webpackConfig.plugin('copy').use(esmResolve('copy-webpack-plugin'), [
{
patterns: copyPatterns,
},
]);
}
webpackConfig.plugin('progress').use(require.resolve(require.resolve('webpackbar')));
webpackConfig.plugin('progress').use(esmResolve('webpackbar'));
// --------------- define -----------
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 = {
level: 'error',
...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 { esmResolve } from '../../../shared';
const defaultTerserOptions = {
compress: {
@ -37,14 +41,22 @@ const defaultTerserOptions = {
},
};
const terserOptions = (config) => ({
terserOptions: deepmerge(defaultTerserOptions, config.terserOptions || {}),
function terserOptions(config: WebpackBuildConfig) {
return {
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) {
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') {
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({
key: 'analyze',
config: {
@ -29,8 +35,8 @@ export default (api) => {
},
enableBy: () => !!process.env.ANALYZE,
});
api.chainWebpack((webpackConfig) => {
webpackConfig.plugin('bundle-analyzer').use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [api.config?.analyze || {}]);
(api as any).chainWebpack((webpackConfig: Config) => {
webpackConfig.plugin('bundle-analyzer').use(esmRequire('webpack-bundle-analyzer').BundleAnalyzerPlugin, [api.config?.analyze || {}]);
return webpackConfig;
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,6 @@
export default (api) => {
import type { IPluginAPI } from '@fesjs/shared';
export default (api: IPluginAPI) => {
api.describe({
key: 'vueLoader',
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',
'modifyBundleConfigOpts',

View File

@ -1,6 +1,7 @@
import type { IPluginAPI } from '@fesjs/shared';
import { name } from '../../package.json';
export default function (api) {
export default function (api: IPluginAPI) {
api.addConfigType(() => ({
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 path from 'path';
import assert from 'node:assert';
import path from 'node:path';
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
// todo 插件导出内容冲突问题待解决
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.');
const source = path.relative(path.basename(basePath), item.source);
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) {
return `export * from '${winPath(source)}';`;
}
assert(Array.isArray(item.specifiers), `specifiers should be Array, but got ${item.specifiers.toString()}.`);
const specifiersStrArr = item.specifiers.map((specifier) => {
assert(Array.isArray(item.specifiers), `specifiers should be Array, but got ${item.specifiers?.toString()}.`);
const specifiersStrArr = item.specifiers!.map((specifier) => {
if (typeof specifier === 'string') {
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.`);
@ -23,8 +41,8 @@ export default function generateExports(basePath, { item, fesExportsHook }) {
return 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.');
return `${specifier.local} as ${specifier.exported}`;
assert((specifier as SpecifierObject).local && (specifier as SpecifierObject).exported, 'local and exported should be supplied.');
return `${(specifier as SpecifierObject).local} as ${(specifier as SpecifierObject).exported}`;
});
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,41 @@
import { copySync } from 'fs-extra/esm';
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() {
copySync('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-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-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-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-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-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-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-watermark](http://fesjs.mumblefe.cn/reference/plugin/plugins/watermark.html) | 水印 |

View File

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

View File

@ -1,71 +1,92 @@
/**
* @copy umi
* https://github.com/umijs/umi/tree/master/packages/core
*/
import { existsSync } from 'fs';
import { extname, join } from 'path';
import assert from 'assert';
import { chalk, chokidar, compatESModuleRequire, deepmerge, cleanRequireCache, lodash, parseRequireDeps, winPath } from '@fesjs/utils';
import type { ServiceInstance, UserConfig } from '../types';
import assert from 'node:assert';
import { existsSync } from 'node:fs';
import { extname, join } from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
import { chalk, chokidar, compatESModuleRequire, deepmerge, lodash, winPath } from '@fesjs/utils';
import joi from 'joi';
import { ServiceStage } from '../service/enums';
import { getUserConfigWithKey, updateUserConfigWithKey } from './utils/configUtils';
import isEqual from './utils/isEqual';
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 {
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.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);
// 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];
if ('default' in config) memo[key] = config.default;
if ('default' in config) {
memo[key] = config.default;
}
return memo;
}, {});
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.');
const userConfig = this.getUserConfig();
const userConfig = await this.getUserConfig();
// 用于提示用户哪些 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
const pluginIds = Object.keys(this.service.plugins);
pluginIds.forEach((pluginId) => {
pluginIds.forEach((pluginId: string) => {
const { key, config = {} } = this.service.plugins[pluginId];
// recognize as key if have schema config
if (!config.schema) return;
if (!config.schema) {
return;
}
const value = getUserConfigWithKey({
key,
userConfig,
});
// 不校验 false 的值,此时已禁用插件
if (value === false) return;
if (value === false) {
return;
}
// do validate
const schema = config.schema(joi);
@ -105,39 +126,33 @@ export default class Config {
return userConfig;
}
getUserConfig() {
async getUserConfig(): Promise<UserConfig> {
const configFile = this.getConfigFile();
this.configFile = configFile;
if (configFile.length > 0) {
// clear require cache and set babel register
const requireDeps = configFile.reduce((memo, file) => {
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));
const configs = await this.requireConfigs(configFile);
return this.mergeConfig(configs);
}
return {};
}
addAffix(file, affix) {
addAffix(file: string, affix: string): string {
const ext = extname(file);
return file.replace(new RegExp(`${ext}$`), `.${affix}${ext}`);
}
requireConfigs(configFiles) {
// eslint-disable-next-line
return configFiles.map((f) => compatESModuleRequire(require(f)));
async requireConfigs(configFiles: string[]): Promise<any[]> {
const models = await Promise.all(configFiles.map((f) => {
// 使用 pathToFileURL 确保在 Windows 下路径格式正确
const fileUrl = pathToFileURL(f).href;
// 避免命中模块缓存
return import(`${fileUrl}?t=${Date.now()}`);
}));
return models.map(m => compatESModuleRequire(m));
}
mergeConfig(...configs) {
let ret = {};
mergeConfig(configs: any[]): UserConfig {
let ret: UserConfig = {};
for (const config of configs) {
// TODO: 精细化处理,比如处理 dotted config key
ret = deepmerge(ret, config);
@ -145,12 +160,14 @@ export default class Config {
return ret;
}
getConfigFile() {
getConfigFile(): string[] {
// TODO: support custom config file
let configFile = CONFIG_FILES.find((f) => existsSync(join(this.cwd, f)));
if (!configFile) return [];
let configFile = CONFIG_FILES.find(f => existsSync(join(this.cwd, f)));
if (!configFile) {
return [];
}
configFile = winPath(configFile);
let envConfigFile;
let envConfigFile: string | undefined;
// 潜在问题:
// .local 和 .env 的配置必须有 configFile 才有效
if (process.env.FES_ENV) {
@ -160,45 +177,46 @@ export default class Config {
}
}
const files = [configFile, envConfigFile, this.localConfig && this.addAffix(configFile, 'local')]
.filter((f) => !!f)
.map((f) => join(this.cwd, f))
.filter((f) => existsSync(f));
return files;
.filter(f => !!f)
.map(f => join(this.cwd, f as string))
.filter(f => existsSync(f));
return files as string[];
}
getWatchFilesAndDirectories() {
getWatchFilesAndDirectories(): string[] {
const fesEnv = process.env.FES_ENV;
const configFiles = lodash.clone(CONFIG_FILES);
CONFIG_FILES.forEach((f) => {
if (this.localConfig) configFiles.push(this.addAffix(f, 'local'));
if (fesEnv) configFiles.push(this.addAffix(f, fesEnv));
if (this.localConfig) {
configFiles.push(this.addAffix(f, 'local'));
}
if (fesEnv) {
configFiles.push(this.addAffix(f, fesEnv as string));
}
});
const configDir = winPath(join(this.cwd, 'config'));
const files = configFiles
.reduce((memo, f) => {
.reduce((memo: string[], f: string) => {
const file = winPath(join(this.cwd, f));
if (existsSync(file)) {
memo = memo.concat(parseRequireDeps(file));
} else {
memo.push(file);
}
return memo;
}, [])
.filter((f) => !f.startsWith(configDir));
.filter(f => !f.startsWith(configDir));
return [configDir].concat(files);
}
watch(opts) {
watch(opts: WatchOptions): () => void {
let paths = this.getWatchFilesAndDirectories();
let userConfig = opts.userConfig;
const watcher = chokidar.watch(paths, {
ignoreInitial: true,
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}`));
const newPaths = this.getWatchFilesAndDirectories();
const diffs = lodash.difference(newPaths, paths);
@ -207,13 +225,15 @@ export default class Config {
paths = paths.concat(diffs);
}
const newUserConfig = this.getUserConfig();
const pluginChanged = [];
const valueChanged = [];
Object.keys(this.service.plugins).forEach((pluginId) => {
const newUserConfig = await this.getUserConfig();
const pluginChanged: Array<{ key: string; pluginId: string }> = [];
const valueChanged: Array<{ key: string; pluginId: string }> = [];
Object.keys(this.service.plugins).forEach((pluginId: string) => {
const { key, config = {} } = this.service.plugins[pluginId];
// recognize as key if have schema config
if (!config.schema) return;
if (!config.schema) {
return;
}
if (!isEqual(newUserConfig[key], userConfig[key])) {
const changed = {
key,
@ -221,7 +241,8 @@ export default class Config {
};
if (newUserConfig[key] === false || userConfig[key] === false) {
pluginChanged.push(changed);
} else {
}
else {
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';
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)) {
return deepmerge(defaultConfig, config);
}
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 @@
/**
* @copy umi
* https://github.com/umijs/umi/tree/master/packages/core
*/
import { join } from 'path';
import { EventEmitter } from 'events';
import assert from 'assert';
import { existsSync } from 'fs';
import { AsyncSeriesWaterfallHook } from 'tapable';
import { lodash, chalk } from '@fesjs/utils';
import type commander from 'commander';
import type {
ApplyPluginsOptions,
CommandOption,
ConfigInstance,
Hook,
Paths,
Plugin,
ResolvePluginsOptions,
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 { readJSONSync } from 'fs-extra/esm';
import { AsyncSeriesWaterfallHook } from 'tapable';
import Config from '../config';
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 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
// 1. duplicated key
export default class Service extends EventEmitter {
cwd;
cwd: string;
pkg;
pkg: Record<string, any>;
skipPluginIds = new Set();
skipPluginIds: Set<string> = new Set();
// lifecycle stage
stage = ServiceStage.uninitialized;
stage: ServiceStage = ServiceStage.uninitialized;
// registered commands
commands = {};
commands: Record<string, CommandOption> = {};
// including plugins
plugins = {};
plugins: Record<string, Plugin> = {};
// 构建
builder = {};
builder: Record<string, any> = {};
// plugin methods
pluginMethods = {};
pluginMethods: Record<string, () => void> = {};
// 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
initialPlugins = [];
initialPlugins: Plugin[] = [];
_extraPresets = [];
_extraPresets: Plugin[] = [];
_extraPlugins = [];
_extraPlugins: Plugin[] = [];
// user config
userConfig;
userConfig: UserConfig;
configInstance;
configInstance: ConfigInstance;
config = null;
// babel register
babelRegister;
config: UserConfig | null = null;
// hooks
hooksByPluginId = {};
hooksByPluginId: Record<string, Hook[]> = {};
hooks = {};
hooks: Record<string, Hook[]> = {};
// paths
paths = {};
paths: Paths;
env;
env: string;
ApplyPluginsType = ApplyPluginsType;
@ -81,31 +125,35 @@ export default class Service extends EventEmitter {
ServiceStage = ServiceStage;
args;
args: Record<string, any> | undefined;
constructor(opts) {
fesPkg: Record<string, any>;
program: commander.Command;
ready: Promise<boolean>;
constructor(opts: ServiceOptions) {
super();
this.cwd = opts.cwd || process.cwd();
// repoDir should be the root dir of repo
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.userConfig = {};
assert(existsSync(this.cwd), `cwd ${this.cwd} does not exist.`);
// register babel before config parsing
this.babelRegister = new BabelRegister();
this.program = this.initCommand();
// load .env or .local.env
this.loadEnv();
// get user config without validation
this.configInstance = new Config({
cwd: this.cwd,
service: this,
localConfig: this.env === 'development',
});
this.userConfig = this.configInstance.getUserConfig();
// get paths
this.paths = getPaths({
@ -114,41 +162,47 @@ export default class Service extends EventEmitter {
env: this.env,
});
this.program = this.initCommand();
// 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,
});
this.ready = this.setup(opts);
}
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;
}
resolvePackage() {
resolvePackage(): Record<string, any> {
try {
// eslint-disable-next-line
return require(join(this.cwd, "package.json"));
} catch (e) {
return readJSONSync(join(this.cwd, 'package.json'));
}
catch {
return {};
}
}
loadEnv() {
loadEnv(): void {
const basePath = join(this.cwd, '.env');
const localPath = `${basePath}.local`;
loadDotEnv(basePath);
@ -158,16 +212,16 @@ export default class Service extends EventEmitter {
loadDotEnv(localPath);
}
async init() {
async init(): Promise<void> {
this.setStage(ServiceStage.init);
await this.initPresetsAndPlugins();
// hooksByPluginId -> hooks
// hooks is mapped with hook key, prepared for applyPlugins()
this.setStage(ServiceStage.initHooks);
Object.keys(this.hooksByPluginId).forEach((id) => {
Object.keys(this.hooksByPluginId).forEach((id: string) => {
const hooks = this.hooksByPluginId[id];
hooks.forEach((hook) => {
hooks.forEach((hook: Hook) => {
const { key } = hook;
hook.pluginId = id;
this.hooks[key] = (this.hooks[key] || []).concat(hook);
@ -190,51 +244,47 @@ export default class Service extends EventEmitter {
// merge paths to keep the this.paths ref
this.setStage(ServiceStage.getPaths);
// config.outputPath may be modified by plugins
if (this.config.outputPath) {
this.paths.absOutputPath = join(this.cwd, this.config.outputPath);
if (this.config?.outputPath) {
this.paths.absOutputPath = join(this.cwd, this.config.outputPath as string);
}
const paths = await this.applyPlugins({
key: 'modifyPaths',
type: ApplyPluginsType.modify,
initialValue: this.paths,
});
Object.keys(paths).forEach((key) => {
this.paths[key] = paths[key];
});
}) as Paths;
Object.assign(this.paths, paths);
}
async setConfig() {
async setConfig(): Promise<void> {
const defaultConfig = await this.applyPlugins({
key: 'modifyDefaultConfig',
type: this.ApplyPluginsType.modify,
initialValue: await this.configInstance.getDefaultConfig(),
});
}) as Record<string, any>;
const initConfig = await this.configInstance.getConfig(defaultConfig);
this.config = await this.applyPlugins({
key: 'modifyConfig',
type: this.ApplyPluginsType.modify,
initialValue: this.configInstance.getConfig({
defaultConfig,
}),
initialValue: initConfig,
});
}
async initPresetsAndPlugins() {
async initPresetsAndPlugins(): Promise<void> {
this.setStage(ServiceStage.initPresets);
this._extraPlugins = [];
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._extraPlugins.push(...this.initialPlugins);
while (this._extraPlugins.length) {
// eslint-disable-next-line
await this.initPlugin(this._extraPlugins.shift());
const plugin = this._extraPlugins.shift();
await this.initPlugin(plugin!);
}
}
getPluginAPI(opts) {
getPluginAPI(opts: { id: string; key: string; service: Service }): PluginAPI {
const pluginAPI = new PluginAPI(opts);
// register built-in methods
@ -246,17 +296,18 @@ export default class Service extends EventEmitter {
});
return new Proxy(pluginAPI, {
get: (target, prop) => {
get: (target, prop: string) => {
// 由于 pluginMethods 需要在 register 阶段可用
// 必须通过 proxy 的方式动态获取最新,以实现边注册边使用的效果
if (this.pluginMethods[prop]) return this.pluginMethods[prop];
if (this.pluginMethods[prop]) {
return this.pluginMethods[prop];
}
if (
[
'applyPlugins',
'ApplyPluginsType',
'EnableBy',
'ConfigChangeType',
'babelRegister',
'stage',
'ServiceStage',
'paths',
@ -273,22 +324,26 @@ export default class Service extends EventEmitter {
'builder',
].includes(prop)
) {
// @ts-expect-error ignore property
return typeof this[prop] === 'function' ? this[prop].bind(this) : this[prop];
}
// @ts-expect-error ignore property
return target[prop];
},
});
}
async applyAPI(opts) {
let ret = opts.apply()(opts.api);
async applyAPI(opts: ApplyAPIOptions): Promise<any> {
const module = await opts.apply();
let ret = module(opts.api);
if (isPromise(ret)) {
ret = await ret;
}
return ret || {};
}
async initPreset(preset) {
async initPreset(preset: Plugin): Promise<void> {
const { id, key, apply } = preset;
preset.isPreset = true;
@ -299,22 +354,23 @@ export default class Service extends EventEmitter {
const { presets, plugins } = await this.applyAPI({
api,
apply,
});
}) as InitPresetResult;
// register extra presets and plugins
if (presets) {
assert(Array.isArray(presets), `presets returned from preset ${id} must be Array.`);
// 插到最前面,下个 while 循环优先执行
this._extraPresets.splice(
0,
0,
...presets.map((path) =>
const _presets = await Promise.all(presets.map(path =>
pathToObj({
type: PluginType.preset,
path,
cwd: this.cwd,
}),
),
));
this._extraPresets.splice(
0,
0,
..._presets,
);
}
@ -322,25 +378,25 @@ export default class Service extends EventEmitter {
const extraPresets = lodash.clone(this._extraPresets);
this._extraPresets = [];
while (extraPresets.length) {
// eslint-disable-next-line
await this.initPreset(extraPresets.shift());
await this.initPreset(extraPresets.shift()!);
}
if (plugins) {
assert(Array.isArray(plugins), `plugins returned from preset ${id} must be Array.`);
this._extraPlugins.push(
...plugins.map((path) =>
const _plugins = await Promise.all(plugins.map(path =>
pathToObj({
type: PluginType.plugin,
path,
cwd: this.cwd,
}),
),
));
this._extraPlugins.push(
..._plugins,
);
}
}
async initPlugin(plugin) {
async initPlugin(plugin: Plugin): Promise<void> {
const { id, key, apply } = plugin;
const api = this.getPluginAPI({
@ -357,25 +413,34 @@ export default class Service extends EventEmitter {
});
}
getPluginOptsWithKey(key) {
getPluginOptsWithKey(key: string): any {
return getUserConfigWithKey({
key,
userConfig: this.userConfig,
});
}
registerPlugin(plugin) {
registerPlugin(plugin: Plugin): void {
this.plugins[plugin.id] = plugin;
}
isPluginEnable(pluginId) {
isPluginEnable(pluginId: string): boolean {
// 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
if (this.userConfig[key] === false) return false;
if (this.userConfig[key] === false) {
return false;
}
// 配置开启
if (enableBy === this.EnableBy.config && !(key in this.userConfig)) {
@ -391,77 +456,74 @@ export default class Service extends EventEmitter {
return true;
}
hasPresets(presetIds) {
hasPresets(presetIds: string[]): boolean {
return presetIds.every((presetId) => {
const preset = this.plugins[presetId];
return preset && preset.isPreset && this.isPluginEnable(presetId);
});
}
hasPlugins(pluginIds) {
hasPlugins(pluginIds: string[]): boolean {
return pluginIds.every((pluginId) => {
const plugin = this.plugins[pluginId];
return plugin && !plugin.isPreset && this.isPluginEnable(pluginId);
});
}
async applyPlugins(opts) {
async applyPlugins<T>(opts: ApplyPluginsOptionsExtended): Promise<T> {
const hooks = this.hooks[opts.key] || [];
switch (opts.type) {
case ApplyPluginsType.add:
if ('initialValue' in opts) {
assert(Array.isArray(opts.initialValue), 'applyPlugins failed, opts.initialValue must be Array if opts.type is add.');
}
// eslint-disable-next-line
const tAdd = new AsyncSeriesWaterfallHook(["memo"]);
// eslint-disable-next-line no-case-declarations
const tAdd = new AsyncSeriesWaterfallHook(['memo']);
for (const hook of hooks) {
if (!this.isPluginEnable(hook.pluginId)) {
if (!this.isPluginEnable(hook.pluginId!)) {
continue;
}
tAdd.tapPromise(
{
name: hook.pluginId,
name: hook.pluginId!,
stage: hook.stage || 0,
// @ts-ignore
before: hook.before,
},
async (memo) => {
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:
// eslint-disable-next-line
const tModify = new AsyncSeriesWaterfallHook(["memo"]);
// eslint-disable-next-line no-case-declarations
const tModify = new AsyncSeriesWaterfallHook(['memo']);
for (const hook of hooks) {
if (!this.isPluginEnable(hook.pluginId)) {
if (!this.isPluginEnable(hook.pluginId!)) {
continue;
}
tModify.tapPromise(
{
name: hook.pluginId,
name: hook.pluginId!,
stage: hook.stage || 0,
// @ts-ignore
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:
// eslint-disable-next-line
const tEvent = new AsyncSeriesWaterfallHook(["_"]);
// eslint-disable-next-line no-case-declarations
const tEvent = new AsyncSeriesWaterfallHook(['_']);
for (const hook of hooks) {
if (!this.isPluginEnable(hook.pluginId)) {
if (!this.isPluginEnable(hook.pluginId!)) {
continue;
}
tEvent.tapPromise(
{
name: hook.pluginId,
name: hook.pluginId!,
stage: hook.stage || 0,
// @ts-ignore
before: hook.before,
},
async () => {
@ -469,22 +531,22 @@ export default class Service extends EventEmitter {
},
);
}
return tEvent.promise();
return tEvent.promise(true) as Promise<T>;
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();
command
.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('一个好用的前端应用解决方案'));
return command;
}
async run({ rawArgv = {}, args = {} }) {
async run({ rawArgv = {}, args = {} }: RunOptions): Promise<any> {
await this.init();
this.setStage(ServiceStage.run);
@ -499,9 +561,9 @@ export default class Service extends EventEmitter {
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.');
Object.keys(this.commands).forEach((command) => {
Object.keys(this.commands).forEach((command: string) => {
const commandOptionConfig = this.commands[command];
const program = this.program;
let c = program.command(command).description(commandOptionConfig.description);
@ -519,7 +581,7 @@ export default class Service extends EventEmitter {
}
if (commandOptionConfig.fn) {
c.action(async () => {
await commandOptionConfig.fn({
await commandOptionConfig.fn!({
rawArgv,
args,
options: c.opts(),
@ -532,13 +594,19 @@ export default class Service extends EventEmitter {
return this.parseCommand();
}
async parseCommand() {
async parseCommand(): Promise<any> {
this.program.on('--help', () => {
// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(` Run ${chalk.cyan('fes <command> --help')} for detailed usage of given command.`);
// eslint-disable-next-line no-console
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);
}
}

View File

@ -1,17 +1,36 @@
/**
* @copy umi
* https://github.com/umijs/umi/tree/master/packages/core
*/
import assert from 'assert';
import type {
CommandOption,
Hook,
Plugin,
PluginConfig,
ServiceInstance,
} from '../types';
import assert from 'node:assert';
import * as utils from '@fesjs/utils';
import { isValidPlugin, pathToObj } from './utils/pluginUtils';
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 {
constructor(opts) {
id: string;
key: string;
service: ServiceInstance;
utils: typeof utils;
logger: typeof utils.logger;
constructor(opts: PluginAPIOptions) {
this.id = opts.id;
this.key = opts.key;
this.service = opts.service;
@ -20,7 +39,7 @@ export default class PluginAPI {
}
// TODO: reversed keys
describe({ id, key, config, enableBy } = {}) {
describe({ id, key, config, enableBy }: DescribeOptions = {}): void {
const { plugins } = this.service;
// this.id and this.key is generated automatically
// so we need to diff first
@ -46,13 +65,13 @@ export default class PluginAPI {
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.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);
}
registerCommand(commandOption) {
registerCommand(commandOption: CommandOption): void {
const { command, fn } = commandOption;
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.');
@ -61,67 +80,69 @@ export default class PluginAPI {
}
// 在 preset 初始化阶段放后面,在插件注册阶段放前面
registerPlugins(plugins) {
async registerPlugins(plugins: (string | Plugin)[]): Promise<void> {
assert(
this.service.stage === ServiceStage.initPresets || this.service.stage === ServiceStage.initPlugins,
'api.registerPlugins() failed, it should only be used in registering stage.',
);
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)
? plugin
: pathToObj({
type: PluginType.plugin,
path: plugin,
path: plugin as string,
cwd: this.service.cwd,
}),
})),
);
if (this.service.stage === ServiceStage.initPresets) {
this.service._extraPlugins.push(...extraPlugins);
} else {
}
else {
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(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)
? preset
? preset as Plugin
: pathToObj({
type: PluginType.preset,
path: preset,
path: preset as string,
cwd: this.service.cwd,
}),
);
));
// 插到最前面,下个 while 循环优先执行
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 (exitsError) {
throw new Error(`api.registerMethod() failed, method ${name} is already exist.`);
} else {
}
else {
return;
}
}
this.service.pluginMethods[name] =
fn ||
this.service.pluginMethods[name]
= fn
// 这里不能用 arrow functionthis 需指向执行此方法的 PluginAPI
// 否则 pluginId 会不会,导致不能正确 skip plugin
function (hookFn) {
const hook = {
|| function (hookFn: any) {
const hook: Partial<Hook> = {
key: name,
...(utils.lodash.isPlainObject(hookFn) ? hookFn : { fn: hookFn }),
};
// @ts-ignore
this.register(hook);
// @ts-expect-error this
this.register(hook as Hook);
};
}
registerBuilder(builder) {
registerBuilder(builder: Record<string, any>): void {
assert(typeof builder === 'object', 'api.registerBuilder() failed, the builder must be object.');
// const { name } = builder;
// 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;
}
skipPlugins(pluginIds) {
skipPlugins(pluginIds: string[]): void {
pluginIds.forEach((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({
key: 'builder',
config: {
@ -8,4 +10,4 @@ export default (api) => {
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';
/**
* dotenv wrapper
* @param envPath string
* @param envPath
*/
export default function loadDotEnv(envPath) {
export default function loadDotEnv(envPath: string): void {
if (existsSync(envPath)) {
const parsed = parse(readFileSync(envPath, 'utf-8')) || {};
Object.keys(parsed).forEach((key) => {
// eslint-disable-next-line no-prototype-builtins
process.env[key] = parsed[key];
});
}

View File

@ -0,0 +1,217 @@
import type { Plugin } from '../../types';
import { basename, dirname, extname, join, relative } from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
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 {
// 使用 pathToFileURL 确保在 Windows 下路径格式正确
const fileUrl = pathToFileURL(path).href;
const ret = await import(fileUrl);
// 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)), '..');

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