feat: create-fes-app支持创建插件项目

This commit is contained in:
wanchun 2022-06-27 19:32:11 +08:00
commit 9f349343b9
18 changed files with 579 additions and 54 deletions

View File

@ -82,6 +82,19 @@ const config: UserConfig<DefaultThemeOptions> = {
},
},
],
[
'@vuepress/docsearch',
{
appId: '4ZF3BCJTP5',
apiKey: '09ff75bbe16bc6e166e103ffb57e10ea',
indexName: 'fesjs',
locales: {
'/': {
placeholder: '搜索文档',
},
},
},
],
],
}

View File

@ -1,13 +1,11 @@
# 贡献指南
## 概览
## 概览
项目仓库借助于 [Yarn Classic 工作区](https://classic.yarnpkg.com/zh-Hans/docs/workspaces) 来实现 [Monorepo](https://en.wikipedia.org/wiki/Monorepo) ,在 `packages` 目录下存放了多个互相关联的独立 Package
项目仓库借助于 [Yarn 工作区](https://classic.yarnpkg.com/zh-Hans/docs/workspaces) 来实现 [ Monorepo](https://en.wikipedia.org/wiki/Monorepo) ,在 `packages` 目录下存放多个互相关联的独立包
- `@fesjs/create-fes-app`: 创建项目模板模块。提供`create-fes-app`命令,提供创建多种类型项目模板的能力。
- `@fesjs/fes`: 入口模块。提供`fes`命令和 API 入口。
- `@fesjs/compiler`: 编译时插件管理模块。定义插件的生命周期、插件配置、插件通讯机制等。
- `@fesjs/runtime`: 运行时插件模块。集成了vue-router定义运行时插件生命周期、插件通讯机制。
@ -20,51 +18,95 @@
- `@fesjs/plugin-${name}`: 官方插件。
- `@fesjs/fes`: `@fesjs/compiler` + `@fesjs/runtime` + `@fesjs/preset-build-in` 的封装。用户只需要安装此依赖和额外的插件或者插件集
- `@fesjs/fes`: 入口模块。提供`fes`命令和 API 入口,封装`@fesjs/compiler` + `@fesjs/runtime` + `@fesjs/preset-build-in`,用户只需要安装此依赖和其他插件
## 开发配置
## 开发准备
开发要求:
- [Node.js](http://nodejs.org) **version 12+**
- [Yarn v1 classic](https://classic.yarnpkg.com/zh-Hans/docs/install)
克隆代码仓库,并安装依赖:
```bash
yarn
```
监听源文件修改:
```bash
yarn build
```
打开另一个终端,开始开发项目文档网站:
```bash
yarn docs:dev
```
- [Node.js v14+](http://nodejs.org)
- [Yarn v1](https://classic.yarnpkg.com/zh-Hans/docs/install)
本项目开发使用的一些主要工具:
- [Jest](https://jestjs.io/) 用于单元测试
- [ESLint](https://eslint.org/) + [Prettier](https://prettier.io/) 用于代码检查和格式化
- [@umi/father](https://github.com/umijs/father) 用于将ES6语法编译成ES5或者CommonJS
## 开发脚本
克隆仓库:
### `yarn build`
```bash
git clone https://github.com/WeBankFinTech/fes.js.git
```
`build` 命令会使用 `father-build` 将 ES6 编译为 CommonJS。
进入`fes.js`目录,安装依赖:
本项目在编写Node端的代码时也用ES6所以你在克隆代码仓库后可能需要先执行该命令来确保项目代码可以顺利运行因为编译后的 JS 文件被 `.gitignore` 排除在仓库以外了。
### `yarn docs:dev`
`docs:` 前缀表明,这些命令是针对文档 (documentation) 进行操作的,即 `docs` 目录。
使用 Vue Press在本地启动文档网站服务器用于实时查看文档效果。
```bash
yarn
```
### 调试功能
在开发完插件代码后需要在template项目中验证功能
- 进入`packages/template`目录
- 执行`yarn dev`
## 贡献文档
文档代码在`docs`目录,基于 [vuepress](https://v2.vuepress.vuejs.org/zh/) 实现。
#### 第一步:启动服务
```bash
yarn docs:dev
```
#### 第二步修改md文件
菜单配置在`/docs/.vuepress/configs/sidebar/zh.ts`中,可以通过此配置找到对应想修改的文档。
如果想添加图片,则可以先把图片添加至`/docs/.vuepress/public`,在代码中使用:
```html
<img :src="$withBase('framework.png')" alt="架构">
```
#### 第三步:查看更新
当md文档保存后文档会自动更新在`http://localhost:8080/`查看。
## 贡献源码
`Fes.js`统一使用`ES Module`规范编写源码,代码会在 node 端和浏览器端执行,所以源码需要编译后才能发布成包,再被执行。
#### 启动编译服务
```bash
yarn dev
```
当我们修改`build.config.js`中配置的包代码时,会把`src`目录的源码编译后到`lib`目录。
#### 修改源码
在了解`Fes.js`设计前提下,修改核心代码或者插件代码。
#### 验证修改内容
根据需求选择模板项目来验证修改内容,比如选择`fes-template`
1. 查看需待验证包是否已经添加到模板项目的依赖中,如果没有则在模板项目的 package.json 中添加包依赖,添加后在根目录执行`yarn`关联依赖
2. 启动模板项目的开发服务
```bash
cd packages/fes-template
yarn dev
```
3. 在项目模板中添加代码验证修改内容
4. 打开`localhost:8000`查看结果
#### 快速调试技巧
每次修改插件或者核心代码后,等待自动编译完,需要在模板目录重新执行`fes dev`,比较费时费力。
可以先在模板的 `.fes` 目录中找到对应临时代码,更改逻辑,验证完后再将变更逻辑保存到正式文件中。
:::warning
直接修改临时文件切莫重新执行`fes dev`,修改会被覆盖。
:::
## 提交PR
1. fork项目!
2. 创建你的功能分支: git checkout -b my-new-feature
3. 本地提交新代码: git commit -am 'Add some feature'
4. 推送本地到服务器分支: git push origin my-new-feature
5. 创建一个PR

View File

@ -36,6 +36,33 @@ API 对象是构建流程管理 Service 类的实例api 提供一些有用的
- **enableBy** 是否开启插件,可配置某些场景下禁用插件。
## 创建插件
##### 第一步:安装`create-fes-app`
```bash
npm i -g @fesjs/create-fes-app
```
##### 第二步:创建插件项目
```bash
create-fes-app pluginName
```
在询问`Pick an template`时选择`Plugin`!
##### 第三步:进入插件目录 & 安装依赖
```bash
cd pluginName & yarn
```
##### 第四步:启动编译
```bash
yarn dev
```
##### 第五步使用插件API完成你的插件可以参考其他插件理解api用法和场景
## 发布到 npm
`@fesjs/preset-`、`@fesjs/plugin-`、`@webank/fes-preset-`、`@webank/fes-plugin-`、`fes-preset-` 和 `fes-plugin-` 开头的依赖会被 Fes.js 自动注册为插件或插件集。

View File

@ -0,0 +1,24 @@
import { Generator } from '@fesjs/utils';
export default class AppGenerator extends Generator {
constructor({ cwd, args, path, targetDir, name }) {
super({
cwd,
args,
});
this.path = path;
this.targetDir = targetDir;
this.name = name;
}
async writing() {
this.copyDirectory({
context: {
version: require('../../package.json').version,
name: this.name,
},
path: this.path,
target: this.targetDir,
});
}
}

View File

@ -6,6 +6,7 @@ import inquirer from 'inquirer';
import { clearConsole } from './utils';
import AppGenerator from './generator/App';
import PluginGenerator from './generator/Plugin';
export default async ({ cwd, args }) => {
if (args.proxy) {
@ -19,12 +20,14 @@ export default async ({ cwd, args }) => {
const result = validateProjectName(name);
if (!result.validForNewPackages) {
console.error(chalk.red(`Invalid project name: "${name}"`));
result.errors && result.errors.forEach((err) => {
console.error(chalk.red.dim(`Error: ${err}`));
});
result.warnings && result.warnings.forEach((warn) => {
console.error(chalk.red.dim(`Warning: ${warn}`));
});
result.errors &&
result.errors.forEach((err) => {
console.error(chalk.red.dim(`Error: ${err}`));
});
result.warnings &&
result.warnings.forEach((warn) => {
console.error(chalk.red.dim(`Warning: ${warn}`));
});
throw new Error('Process exited');
}
if (fs.pathExistsSync(targetDir) && !args.merge) {
@ -36,8 +39,8 @@ export default async ({ cwd, args }) => {
{
name: 'ok',
type: 'confirm',
message: 'Generate project in current directory?'
}
message: 'Generate project in current directory?',
},
]);
if (!ok) {
return null;
@ -52,9 +55,9 @@ export default async ({ cwd, args }) => {
choices: [
{ name: 'Overwrite', value: 'overwrite' },
{ name: 'Merge', value: 'merge' },
{ name: 'Cancel', value: false }
]
}
{ name: 'Cancel', value: false },
],
},
]);
if (!action) {
return null;
@ -75,17 +78,18 @@ export default async ({ cwd, args }) => {
choices: [
{ name: 'PC, suitable for management desk front-end applications', value: 'pc' },
{ name: 'H5, suitable for mobile applications', value: 'h5' },
{ name: 'Cancel', value: false }
]
}
{ name: 'Plugin, suitable for fes plugin', value: 'plugin' },
{ name: 'Cancel', value: false },
],
},
]);
if (template) {
if (template === 'pc' || template === 'h5') {
const generator = new AppGenerator({
cwd,
args,
targetDir,
path: path.join(__dirname, `../templates/app/${template}`)
path: path.join(__dirname, `../templates/app/${template}`),
});
await generator.run();
console.log();
@ -94,5 +98,20 @@ export default async ({ cwd, args }) => {
console.log('$ yarn');
console.log('$ yarn dev');
console.log();
} else if (template === 'plugin') {
const generator = new PluginGenerator({
cwd,
args,
targetDir,
path: path.join(__dirname, '../templates/plugin'),
name,
});
await generator.run();
console.log();
console.log(chalk.green(`plugin ${projectName} created successfully, please execute the following command to use:`));
console.log(`$ cd ${projectName}`);
console.log('$ yarn');
console.log('$ yarn dev');
console.log();
}
};

View File

@ -0,0 +1,16 @@
# http://editorconfig.org
root = true
lib
[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false

View File

@ -0,0 +1,21 @@
module.exports = {
extends: ['@webank/eslint-config-webank/vue.js'],
globals: {
// 这里填入你的项目需要的全局变量
// 这里值为 false 表示这个全局变量不允许被重新赋值,比如:
//
// Vue: false
__DEV__: false,
},
rules: {
'vue/comment-directive': 'off',
'global-require': 'off',
'import/no-unresolved': 'off',
'no-restricted-syntax': 'off',
'no-undefined': 'off',
'vue/valid-template-root': 'off',
},
env: {
jest: true,
},
};

View File

@ -0,0 +1,2 @@
node_modules
lib

View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "none"
}

View File

@ -0,0 +1,3 @@
module.exports = {
copy: ['runtime'],
};

View File

@ -0,0 +1,49 @@
{
"name": "fes-plugin-{{{name}}}",
"version": "3.0.0",
"description": "一个fes.js插件",
"main": "lib/index.js",
"files": [
"lib",
"README.md",
"types.d.ts"
],
"scripts": {
"dev": "node scripts/build.js --watch",
"build": "node scripts/build.js",
"lint": "eslint -c ./.eslintrc.js --ext .js,.jsx,.vue,.ts"
},
"license": "MIT",
"keywords": [
],
"dependencies": {
},
"devDependencies": {
"@babel/core": "^7.15.0",
"@babel/preset-env": "^7.15.0",
"@webank/eslint-config-webank": "^1.2.3",
"chalk": "^4.1.2",
"chokidar": "^3.5.2",
"deepmerge": "^4.2.2",
"fs-extra": "^10.0.0",
"husky": "^4.3.0",
"lint-staged": "^10.4.0",
"yargs-parser": "^20.2.9"
},
"peerDependencies": {
"@fesjs/fes": "^3.0.0-beta.0",
"vue": "^3.0.5"
},
"lint-staged": {
"*.{js,jsx,vue,ts}": [
"eslint --format=codeframe"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"typings": "./types.d.ts"
}

View File

@ -0,0 +1,144 @@
// 关闭 import 规则
/* eslint import/no-extraneous-dependencies: 0 */
const fs = require('fs');
const fse = require('fs-extra');
const path = require('path');
const merge = require('deepmerge');
const chokidar = require('chokidar');
const chalk = require('chalk');
const argv = require('yargs-parser')(process.argv.slice(2));
const compiler = require('./compiler');
const randomColor = require('./randomColor');
const pkg = require('../package.json');
const ESM_OUTPUT_DIR = 'es';
const NODE_CJS_OUTPUT_DIR = 'lib';
const SOURCE_DIR = 'src';
const CONFIG_FILE_NAME = 'build.config.js';
const GLOBAL_CONFIG_PATH = path.join(process.cwd(), CONFIG_FILE_NAME);
const DEFAULT_CONFIG = {
target: 'node',
};
function genLog(pkgName) {
return (msg) => {
console.log(`${randomColor(pkgName)}: ${msg}`);
};
}
function genShortPath(filePath) {
const codePath = filePath.split(`/${SOURCE_DIR}/`)[1];
return `${SOURCE_DIR}/${codePath}`;
}
function getPkgSourcePath() {
return path.join(process.cwd(), SOURCE_DIR);
}
function getOutputPath(config) {
if (config.target === 'browser') {
return path.join(process.cwd(), ESM_OUTPUT_DIR);
}
return path.join(process.cwd(), NODE_CJS_OUTPUT_DIR);
}
function getGlobalConfig() {
if (fs.existsSync(GLOBAL_CONFIG_PATH)) {
const userConfig = require(GLOBAL_CONFIG_PATH);
return merge(DEFAULT_CONFIG, userConfig);
}
return DEFAULT_CONFIG;
}
function cleanBeforeCompilerResult(log) {
const esmOutputDir = path.join(process.cwd(), ESM_OUTPUT_DIR);
const cjsOutputDir = path.join(process.cwd(), NODE_CJS_OUTPUT_DIR);
if (fs.existsSync(esmOutputDir)) {
log(chalk.gray(`Clean ${ESM_OUTPUT_DIR} directory`));
fse.removeSync(esmOutputDir);
}
if (fs.existsSync(cjsOutputDir)) {
log(chalk.gray(`Clean ${NODE_CJS_OUTPUT_DIR} directory`));
fse.removeSync(cjsOutputDir);
}
}
function transformFile(filePath, outputPath, config, log) {
if (/\.[jt]sx?$/.test(path.extname(filePath))) {
try {
const code = fs.readFileSync(filePath, 'utf-8');
const shortFilePath = genShortPath(filePath);
const transformedCode = compiler(code, config);
const type = config.target === 'browser' ? ESM_OUTPUT_DIR : NODE_CJS_OUTPUT_DIR;
log(`Transform to ${type} for ${config.target === 'browser' ? chalk.yellow(shortFilePath) : chalk.blue(shortFilePath)}`);
fse.outputFileSync(outputPath, transformedCode);
} catch (error) {
console.error(error);
}
} else {
fse.copySync(filePath, outputPath);
}
}
function compilerPkg(codeDir, outputDir, config, log) {
const files = fs.readdirSync(codeDir);
files.forEach((file) => {
const filePath = path.join(codeDir, file);
const outputFilePath = path.join(outputDir, file);
const fileStats = fs.lstatSync(filePath);
if (config.copy.includes(file)) {
fse.copySync(filePath, outputFilePath);
} else if (fileStats.isDirectory(filePath) && !/__tests__/.test(file)) {
fse.ensureDirSync(outputFilePath);
compilerPkg(filePath, outputFilePath, config, log);
} else if (fileStats.isFile(filePath)) {
transformFile(filePath, outputFilePath, config, log);
}
});
}
function watchFile(dir, outputDir, config, log) {
chokidar
.watch(dir, {
ignoreInitial: true,
})
.on('all', (event, changeFile) => {
// 修改的可能是一个目录,一个文件,一个需要 copy 的文件 or 目录
const shortChangeFile = genShortPath(changeFile);
const outputPath = changeFile.replace(dir, outputDir);
const stat = fs.lstatSync(changeFile);
log(`[${event}] ${shortChangeFile}`);
if (config.resolveCopy.some((item) => changeFile.startsWith(item))) {
fse.copySync(changeFile, outputPath);
} else if (stat.isFile()) {
transformFile(changeFile, outputPath, config, log);
} else if (stat.isDirectory()) {
compilerPkg(changeFile, outputPath, config);
}
});
}
function main() {
const sourceCodeDir = getPkgSourcePath();
const pkgName = pkg.name;
if (fs.existsSync(sourceCodeDir)) {
const log = genLog(pkgName);
const config = getGlobalConfig();
const outputDir = getOutputPath(config);
cleanBeforeCompilerResult(log);
const type = config.target === 'browser' ? ESM_OUTPUT_DIR : NODE_CJS_OUTPUT_DIR;
log(chalk.white(`Build ${type} with babel`));
compilerPkg(sourceCodeDir, outputDir, config, log);
if (argv.watch) {
log(chalk.magenta(`Start watch ${SOURCE_DIR} directory...`));
watchFile(sourceCodeDir, outputDir, config, log);
}
}
}
main();

View File

@ -0,0 +1,52 @@
// 关闭 import 规则
/* eslint import/no-extraneous-dependencies: 0 */
const babel = require('@babel/core');
function transform(code, options) {
const result = babel.transformSync(code, options);
return result.code;
}
function transformNodeCode(code) {
return transform(code, {
presets: [
[
'@babel/preset-env',
{
modules: 'cjs',
targets: { node: '12' },
},
],
],
});
}
function transformBrowserCode(code) {
// 因为 fes.js 在生产打包的时候,会处理所有的 node_modules 下的文件,确保不会丢失必要 polyfill
// 因此这里不对 polyfill 进行处理,避免全局污染
return transform(code, {
presets: [
[
'@babel/preset-env',
{
modules: false,
useBuiltIns: false,
targets: { chrome: '51' },
},
],
],
});
}
function compiler(code, config) {
if (!config.target || config.target === 'node') {
return transformNodeCode(code);
}
if (config.target === 'browser') {
return transformBrowserCode(code);
}
throw new Error(`config target error: ${config.target}, only can use 'node' and 'browser'`);
}
module.exports = compiler;

View File

@ -0,0 +1,35 @@
/* eslint import/no-extraneous-dependencies: 0 */
const chalk = require('chalk');
const colors = [
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'gray',
'redBright',
'greenBright',
'yellowBright',
'blueBright',
'magentaBright',
'cyanBright',
];
let index = 0;
const cache = {};
module.exports = function (pkg) {
if (!cache[pkg]) {
const color = colors[index];
const str = chalk[color].bold(pkg);
cache[pkg] = str;
if (index === colors.length - 1) {
index = 0;
} else {
index += 1;
}
}
return cache[pkg];
};

View File

@ -0,0 +1,58 @@
import { join } from 'path';
import { readFileSync } from 'fs';
import { name } from '../package.json';
const namespace = 'plugin-{{{name}}}';
export default (api) => {
api.describe({
key: '{{{name}}}',
config: {
schema(joi) {
return joi.object();
},
default: {}
}
});
const {
utils: { Mustache }
} = api;
const absoluteFilePath = join(namespace, 'core.js');
const absRuntimeFilePath = join(namespace, 'runtime.js');
api.onGenerateFiles(() => {
// 运行时执行的代码全部copy到临时目录此时不需要编译稍后webpack会编译临时目录代码
api.copyTmpFiles({
namespace,
path: join(__dirname, 'runtime'),
ignore: ['.tpl']
});
// 有些运行时代码通过配置生成则通过tpl写入
api.writeTmpFile({
path: absoluteFilePath,
content: Mustache.render(
readFileSync(join(__dirname, 'runtime/core.tpl'), 'utf-8'),
{
}
)
});
});
if (api.builder.name === 'vite') {
// 处理vite构建器
} else if(api.builder.name === 'webpack') {
// 处理webpack构建器
}
// 注册运行时插件
api.addRuntimePlugin(() => `@@/${absRuntimeFilePath}`);
// 注册代码提示
api.addConfigType(() => ({
source: name,
}));
};

View File

@ -0,0 +1 @@
// 通过配置生成的代码

View File

@ -0,0 +1,5 @@
// 配置运行时插件
export function onAppCreated({ app }) {
console.log(app);
}

View File

@ -0,0 +1,10 @@
import {} from '@fesjs/fes';
declare module "@fesjs/fes" {
interface PluginBuildConfig {
}
interface PluginRuntimeConfig {
}
}