├── src ├── tsconfig.json └── index.ts ├── .gitignore ├── .prettierrc ├── tsconfig.json ├── cli.js ├── README.md └── package.json /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /test.js 4 | /yarn.lock 5 | /basementToken.json 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "proseWrap": "never", 6 | "overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "moduleResolution": "node", 5 | "strict": true, 6 | "declaration": true, 7 | "noUnusedLocals": true, 8 | "esModuleInterop": true, 9 | "outDir": "./dist", 10 | "module": "commonjs", 11 | "lib": ["ESNext"] 12 | }, 13 | "include": ["./"] 14 | } 15 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const yParser = require('yargs-parser'); 4 | 5 | const args = yParser(process.argv.slice(2), { 6 | alias: { 7 | version: ['v'], 8 | help: ['h'], 9 | }, 10 | boolean: ['version'], 11 | }); 12 | 13 | if (args.version) { 14 | console.log(require('./package.json').version); 15 | process.exit(0); 16 | } 17 | 18 | const cwd = process.cwd(); 19 | require('./dist') 20 | .default({ 21 | file: args.file, 22 | target: args.target, 23 | appId: args.appId, 24 | masterKey: args.masterKey, 25 | }) 26 | .then(() => { 27 | console.log('Success!'); 28 | }) 29 | .catch((e) => { 30 | console.error(`Failure!`); 31 | console.error(e); 32 | }); 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ae-html-to-react 2 | 3 | [![NPM version](https://img.shields.io/npm/v/ae-html-to-react.svg?style=flat)](https://npmjs.org/package/ae-html-to-react) 4 | 5 | 一键转换 AE 编译出的 html 动画文件为 React 格式。 6 | 7 | ## Install 8 | 9 | ```bash 10 | $ yarn global add ae-html-to-react 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```bash 16 | $ ae-html-to-react --file /path/to/html/file --target /path/to/component --appId xxx --masterKey xxx 17 | ``` 18 | 19 | ## Options 20 | 21 | ### file 22 | 23 | AE 产出的 HTML 文件,CSS 会自动查找。 24 | 25 | ### target 26 | 27 | 目标组件目录,比如 /path/to/project/src/components/FooBar 。 28 | 29 | ### appId 30 | 31 | basement 上申请。 32 | 33 | ### masterKey 34 | 35 | basement 上申请。 36 | 37 | ## LICENSE 38 | 39 | MIT 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ae-html-to-react", 3 | "version": "1.0.2", 4 | "main": "dist/index.js", 5 | "bin": { 6 | "ae-html-to-react": "cli.js" 7 | }, 8 | "files": [ 9 | "dist", 10 | "cli.js" 11 | ], 12 | "scripts": { 13 | "dev": "tsc -w --incremental --p src", 14 | "build": "rimraf dist && tsc -p src", 15 | "prepublishOnly": "npm run build && np --no-cleanup --yolo --no-publish" 16 | }, 17 | "dependencies": { 18 | "@alipay/basement": "^3.5.0", 19 | "mkdirp": "^1.0.4", 20 | "postcss": "^8.1.8", 21 | "urllib": "^2.36.1", 22 | "yargs-parser": "^20.2.4" 23 | }, 24 | "devDependencies": { 25 | "@types/mkdirp": "^1.0.1", 26 | "@types/node": "^14.14.9", 27 | "np": "^7.0.0", 28 | "prettier": "^2.2.0", 29 | "rimraf": "^3.0.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | // @ts-ignore 3 | import Basement from '@alipay/basement'; 4 | import postcss from 'postcss'; 5 | import urllib from 'urllib'; 6 | import { join, dirname, basename } from 'path'; 7 | import mkdirp from 'mkdirp'; 8 | 9 | export default async function (opts: { 10 | file: string; 11 | target: string; 12 | appId: string; 13 | masterKey: string; 14 | }) { 15 | mkdirp.sync(opts.target); 16 | const componentName = basename(opts.target); 17 | 18 | // js 19 | const js = await transformJS({ file: opts.file, componentName }); 20 | writeFileSync(join(opts.target, 'index.tsx'), js, 'utf-8'); 21 | 22 | // css 23 | const cssFile = opts.file.replace(/\.html$/, '_style.css'); 24 | const css = await transformCSS({ 25 | file: cssFile, 26 | animKey: `${componentName}Key`, 27 | ...(opts.appId && 28 | opts.masterKey && { 29 | uploadImages: { 30 | appId: opts.appId, 31 | masterKey: opts.masterKey, 32 | }, 33 | }), 34 | }); 35 | writeFileSync(join(opts.target, 'index.less'), css, 'utf-8'); 36 | } 37 | 38 | export async function transformJS(opts: { 39 | file: string; 40 | componentName: string; 41 | }) { 42 | const content = readFileSync(opts.file, 'utf-8'); 43 | const m = content.match(/([\s\S]+)<\/body>/); 44 | if (m && m[1]) { 45 | const body = m[1].split(/[\r\n]/).map((line) => { 46 | line.match(/class=""/); 47 | return line 48 | .replace(/\sclass=\"(.+?)\"/, (a, b) => { 49 | const styles = b.split(' ').map((name: string) => { 50 | return `styles.${name}`; 51 | }); 52 | return ` className={classnames(${styles.join(', ')})}`; 53 | }) 54 | .replace(/\sid=\".+?\"/, '') 55 | .replace(/\sAELayerName=\".+?\"/, '') 56 | .replace(/><\/div>/, ' />'); 57 | }); 58 | return ` 59 | import React from 'react'; 60 | import classnames from 'classnames'; 61 | import styles from './index.less'; 62 | 63 | const ${opts.componentName}: React.FC = () => { 64 | return ( 65 | ${body.join('\n').trim()} 66 | ); 67 | }; 68 | 69 | export default ${opts.componentName}; 70 | `; 71 | } else { 72 | throw new Error(' 内元素匹配失败'); 73 | } 74 | } 75 | 76 | export async function transformCSS(opts: { 77 | file: string; 78 | animKey?: string; 79 | uploadImages?: { 80 | appId: string; 81 | masterKey: string; 82 | }; 83 | }) { 84 | const content = readFileSync(opts.file, 'utf-8'); 85 | const root = postcss.parse(content, {}); 86 | 87 | // 1. 添加 background-contain 88 | // 2. 压缩图片 89 | // 3. 自动上传图片文件 90 | // 4. 修改 Anim Name 91 | const imageCache: any = {}; 92 | for (const node of root.nodes) { 93 | if (node.type === 'rule' && node.nodes) { 94 | for (const decl of node.nodes) { 95 | if ( 96 | decl.type === 'decl' && 97 | (decl.prop === 'background' || decl.prop === 'background-image') && 98 | decl.value.startsWith('url(') 99 | ) { 100 | console.log(`处理背景图规则:${decl.value}`); 101 | if (opts.uploadImages) { 102 | const m = decl.value.match(/url\(\"(.+?)\"\)/); 103 | if (m && m[1]) { 104 | const image = decodeURI(m[1]); 105 | console.log(`处理背景图:${image}`); 106 | const file = join(dirname(opts.file), image); 107 | console.log(`上传图片: ${file}`); 108 | let res: any; 109 | if (imageCache[file]) { 110 | console.log('图片已存在于缓存中'); 111 | res = imageCache[file]; 112 | decl.value = `url(${res.url})`; 113 | } else { 114 | res = await uploadImage({ 115 | file, 116 | appId: opts.uploadImages.appId, 117 | masterKey: opts.uploadImages.masterKey, 118 | }); 119 | if (res && res.url) { 120 | imageCache[file] = res; 121 | console.log(`图片上传成功: ${res.url}`); 122 | decl.value = `url(${res.url})`; 123 | } else { 124 | console.error(`图片上传失败`); 125 | } 126 | } 127 | } 128 | } 129 | console.log( 130 | `添加 background-size: contain; 和 uc-perf-stat-ignore: image;`, 131 | ); 132 | decl.after(`uc-perf-stat-ignore: image;`); 133 | decl.after(`background-size: contain;`); 134 | } 135 | if (opts.animKey && decl.type === 'decl' && decl.prop === 'animation') { 136 | decl.value = decl.value.replace('BX_AniKey', opts.animKey); 137 | } 138 | } 139 | } 140 | if (opts.animKey && node.type === 'atrule' && node.name === 'keyframes') { 141 | node.params = node.params.replace('BX_AniKey', opts.animKey); 142 | } 143 | } 144 | 145 | return root.toResult().css; 146 | } 147 | 148 | export async function uploadImage(opts: { 149 | file: string; 150 | appId: string; 151 | masterKey: string; 152 | }) { 153 | const basement = new Basement({ 154 | // 获取 appId 和 masterKey: https://basement.alipay.com/doc/detail/ziarab#da1386cd 155 | appId: opts.appId, 156 | masterKey: opts.masterKey, 157 | urllib, 158 | endpoint: 'https://basement-gzone.alipay.com', 159 | }); 160 | return await basement.file.upload(basename(opts.file), opts.file, { 161 | mode: 'public', 162 | force: false, 163 | }); 164 | } 165 | --------------------------------------------------------------------------------