diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..22f970f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Test + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: test + run: | + yarn + yarn build + yarn test + # yarn test --coverage + # - name: report + # uses: coverallsapp/github-action@v1.0.1 + # with: + # github-token: ${{ secrets.GITHUB_TOKEN }} + # path-to-lcov: ./coverage/lcov.info diff --git a/lerna.json b/lerna.json index 65827c7..5b309a4 100644 --- a/lerna.json +++ b/lerna.json @@ -1,12 +1,12 @@ { "version": "independent", "npmClient": "yarn", - "useWorkspaces": true, "packages": ["packages/*"], "ignoreChanges": [ "**/docs/**", "**/fixtures/**", "**/__tests__/**", + "**/__snapshots__/**", "**/test/**", "**/*.mdx", "**/*.md" diff --git a/packages/reiconify-loader/README.md b/packages/reiconify-loader/README.md index afb73b0..8d0de5d 100644 --- a/packages/reiconify-loader/README.md +++ b/packages/reiconify-loader/README.md @@ -16,8 +16,15 @@ module.exports = { oneOf: [ { resourceQuery: /react/, - use: 'reiconify-loader', + use: { + loader: 'reiconify-loader', + // whether to use React Native + // options: { + // native: true, + // }, + }, }, + // optional fallback { use: 'file-loader', }, @@ -31,8 +38,12 @@ module.exports = { Import icons: ```js +// types for web /// +// types for React Native +/// + // import React icon import AlarmIcon from './icons/alarm.svg?react' diff --git a/packages/reiconify-loader/index.ts b/packages/reiconify-loader/index.ts index 979d0e7..4a43f64 100644 --- a/packages/reiconify-loader/index.ts +++ b/packages/reiconify-loader/index.ts @@ -2,9 +2,15 @@ import type {LoaderContext} from 'webpack' import {callbackify} from 'util' import transform from 'reiconify/lib/transform' +/** + * SVG to React Component loader + */ export default function reiconifyLoader( - this: LoaderContext<{}>, + this: LoaderContext<{native?: boolean}>, source: string ) { - callbackify(() => transform(source, {baseName: 'base-icon'}))(this.async()) + const {native} = this.getOptions() + callbackify(() => + transform(source, native ? {native} : {baseName: 'base-icon'}) + )(this.async()) } diff --git a/packages/reiconify-loader/native.d.ts b/packages/reiconify-loader/native.d.ts new file mode 100644 index 0000000..b603fd0 --- /dev/null +++ b/packages/reiconify-loader/native.d.ts @@ -0,0 +1,6 @@ +import type {SvgProps} from 'react-native-svg' + +declare module '*?react' { + const Icon: React.FC + export default Icon +} diff --git a/packages/reiconify-loader/package.json b/packages/reiconify-loader/package.json index 893b834..e4dab29 100644 --- a/packages/reiconify-loader/package.json +++ b/packages/reiconify-loader/package.json @@ -12,6 +12,7 @@ "license": "MIT", "files": [ "client.d.ts", + "native.d.ts", "dist" ], "jest": { @@ -27,9 +28,17 @@ "base-icon": "^2.2.1" }, "peerDependencies": { - "webpack": "^4 || ^5" + "webpack": "^4 || ^5", + "react": "*", + "react-native-svg": "*" + }, + "peerDependenciesMeta": { + "react-native-svg": { + "optional": true + } }, "devDependencies": { + "identity-obj-proxy": "^3.0.0", "memfs": "^3.4.7", "webpack": "^5.74.0" } diff --git a/packages/reiconify-loader/test/__snapshots__/index.spec.ts.snap b/packages/reiconify-loader/test/__snapshots__/index.spec.ts.snap index 2018bd9..635b392 100644 --- a/packages/reiconify-loader/test/__snapshots__/index.spec.ts.snap +++ b/packages/reiconify-loader/test/__snapshots__/index.spec.ts.snap @@ -19,3 +19,22 @@ export { }; " `; + +exports[`compiler svg for RN 1`] = ` +"var __assign = Object.assign; +import React from "react"; +import * as svg from "react-native-svg"; +function Icon(props) { + return /* @__PURE__ */ React.createElement(svg.Svg, __assign(__assign({ + width: "24", + height: "24", + viewBox: "0 0 24 24" + }, props), size && {width: size, height: size}), /* @__PURE__ */ React.createElement(svg.Path, { + d: "M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" + })); +} +export { + Icon as default +}; +" +`; diff --git a/packages/reiconify-loader/test/compiler.ts b/packages/reiconify-loader/test/compiler.ts index 92554ec..43073c7 100644 --- a/packages/reiconify-loader/test/compiler.ts +++ b/packages/reiconify-loader/test/compiler.ts @@ -3,7 +3,7 @@ import webpack from 'webpack' import {createFsFromVolume, Volume} from 'memfs' // https://webpack.js.org/contribute/writing-a-loader/#testing -export default function compiler(fixture) { +export default function compiler(fixture, native = false) { const compiler = webpack({ context: __dirname, entry: `./${fixture}`, @@ -18,6 +18,12 @@ export default function compiler(fixture) { path: path.resolve(__dirname), filename: 'bundle.js', }, + resolve: { + alias: { + // skip installing react-native-svg and react-native + 'react-native-svg': 'identity-obj-proxy', + }, + }, module: { rules: [ { @@ -25,7 +31,12 @@ export default function compiler(fixture) { oneOf: [ { resourceQuery: /react/, - use: require.resolve('../index.ts'), + use: { + loader: require.resolve('../index.ts'), + options: { + native, + }, + }, }, ], }, diff --git a/packages/reiconify-loader/test/index.spec.ts b/packages/reiconify-loader/test/index.spec.ts index 14d5b0f..9f62248 100644 --- a/packages/reiconify-loader/test/index.spec.ts +++ b/packages/reiconify-loader/test/index.spec.ts @@ -5,3 +5,9 @@ test('compiler svg', async () => { const output = stats.toJson({source: true})?.modules?.[0]?.modules?.[0].source expect(output).toMatchSnapshot() }) + +test('compiler svg for RN', async () => { + const stats = await compiler('./icons/check.svg?react', true) + const output = stats.toJson({source: true})?.modules?.[0]?.modules?.[0].source + expect(output).toMatchSnapshot() +}) diff --git a/packages/reiconify/lib/defaultConfig.js b/packages/reiconify/lib/defaultConfig.js index c943e15..a3aa7f3 100644 --- a/packages/reiconify/lib/defaultConfig.js +++ b/packages/reiconify/lib/defaultConfig.js @@ -1,6 +1,20 @@ const pascalCase = require('pascal-case') const template = (data) => { + if (data.native) { + const jsxWithProps = data.jsxString.replace( + //, + (match, group) => + `` + ) + return ` + import React from 'react' + import * as svg from 'react-native-svg' + export default function ${data.name}(props) { + return ${jsxWithProps} + } +`.trim() + } const hasBaseName = !!data.baseName const tag = hasBaseName ? `SVG` : 'svg' const jsxWithProps = data.jsxString @@ -96,6 +110,9 @@ const indexTemplate = (names) => { return lines.join('\n') } +/** + * @type {import('./types').Options} + */ const defaults = { name: 'Icon', baseName: undefined, @@ -108,6 +125,7 @@ const defaults = { indexTemplate, svgoPlugins: [], camelCaseProps: true, + native: false, } module.exports = defaults diff --git a/packages/reiconify/lib/svg2jsx.js b/packages/reiconify/lib/svg2jsx.js index 98b6cf7..adbb93f 100644 --- a/packages/reiconify/lib/svg2jsx.js +++ b/packages/reiconify/lib/svg2jsx.js @@ -4,6 +4,7 @@ const JSON5 = require('json5') const mapKeys = require('lodash/mapKeys') const camelCase = require('lodash/camelCase') const styleToObject = require('style-to-object') +const pascalCase = require('pascal-case') const toCamelCase = (s) => s.replace(/([-_:])([a-z])/g, (s, a, b) => b.toUpperCase()) @@ -62,6 +63,24 @@ const camelCaseNamespaceProps = { }, } +/** + * @type {import('svgo').PluginDef} + */ +const reactNativeSVG = { + name: 'reactNativeSVG', + description: 'Convert SVG to React Native SVG', + fn: () => { + return { + element: { + enter: (item) => { + // use namespace import + item.name = `svg.${pascalCase(item.name)}` + }, + }, + } + }, +} + // svgo 默认就会启用一批插件,参考: // https://github.com/svg/svgo/issues/646 // https://github.com/BohemianCoding/svgo-compressor/blob/develop/src/defaultConfig.js @@ -113,6 +132,7 @@ const createOptimizer = (options) => { .concat(options.svgoPlugins) .concat(options.camelCaseProps ? camelCaseProps : []) .concat(options.camelCaseNamespaceProps ? camelCaseNamespaceProps : []) + .concat(options.native ? reactNativeSVG : []) return async (svg) => { const {data} = svgo.optimize(svg, {plugins}) diff --git a/packages/reiconify/lib/transform.js b/packages/reiconify/lib/transform.js index 0aaef2d..09331ba 100644 --- a/packages/reiconify/lib/transform.js +++ b/packages/reiconify/lib/transform.js @@ -2,6 +2,12 @@ const defaultConfig = require('./defaultConfig') const svg2jsx = require('./svg2jsx') const esTransform = require('./esTransform') +/** + * Convert SVG to JS + * @param {string} svg + * @param {import('./types').Options} options + * @returns {Promise} + */ const transform = async (svg, options) => { const { name, @@ -11,18 +17,22 @@ const transform = async (svg, options) => { defaultProps, svgoPlugins, camelCaseProps, + native = false, usePrettier = false, format = 'esm', + jsx = 'react', } = Object.assign({}, defaultConfig, options) - const jsxString = await svg2jsx(svg, {svgoPlugins, camelCaseProps}) + const jsxString = await svg2jsx(svg, {svgoPlugins, native, camelCaseProps}) let code = template({ name, baseName, baseClassName, defaultProps, jsxString, + native, }) - if (format !== 'jsx') { + // TODO: upgrade esbuild to support jsx automatic runtime + if (jsx !== 'preserve') { code = await esTransform(code, {format}) } return usePrettier ? require('./prettier')(code) : code diff --git a/packages/reiconify/lib/transformFiles.js b/packages/reiconify/lib/transformFiles.js index e14e99b..de89f06 100644 --- a/packages/reiconify/lib/transformFiles.js +++ b/packages/reiconify/lib/transformFiles.js @@ -95,7 +95,7 @@ const transformFiles = async (options = {}) => { camelCaseProps, // format source only usePrettier: true, - format: 'jsx', + jsx: 'preserve', }) return {name, code} }) diff --git a/packages/reiconify/lib/types.d.ts b/packages/reiconify/lib/types.d.ts new file mode 100644 index 0000000..d16e12e --- /dev/null +++ b/packages/reiconify/lib/types.d.ts @@ -0,0 +1,16 @@ +export type Options = { + name?: 'Icon' + baseName?: string + baseClassName?: string + template?: (opts: Options) => string + baseTemplate?: (opts: Options) => string + defaultProps?: Record + baseDefaultProps?: Record + filenameTemplate?: (filename: string) => string + indexTemplate?: (names: string[]) => string + svgoPlugins?: import('svgo').PluginDef[] + format?: 'esm' | 'cjs' + jsx?: 'transform' | 'preserve' + camelCaseProps?: boolean + native?: boolean +} diff --git a/packages/reiconify/test/__snapshots__/resolve-config.spec.js.snap b/packages/reiconify/test/__snapshots__/resolve-config.spec.js.snap index d907058..7b09f5a 100644 --- a/packages/reiconify/test/__snapshots__/resolve-config.spec.js.snap +++ b/packages/reiconify/test/__snapshots__/resolve-config.spec.js.snap @@ -13,6 +13,7 @@ exports[`resolveConfig gets default config 1`] = ` "filenameTemplate": [Function], "indexTemplate": [Function], "name": "Icon", + "native": false, "svgoPlugins": [], "template": [Function], } @@ -31,6 +32,7 @@ exports[`resolveConfig overwrites default config 1`] = ` "filenameTemplate": [Function], "indexTemplate": [Function], "name": "Icon", + "native": false, "svgoPlugins": [ { "removeAttrs": { diff --git a/packages/vite-plugin-reiconify/README.md b/packages/vite-plugin-reiconify/README.md index 91b78b1..88ab63a 100644 --- a/packages/vite-plugin-reiconify/README.md +++ b/packages/vite-plugin-reiconify/README.md @@ -14,8 +14,10 @@ import reiconify from 'vite-plugin-reiconify' export default { plugins: [ react(), - // + // for web reiconify(), + // for React Native + // reiconify({native: true}), ], } ``` @@ -24,8 +26,13 @@ Import icons: ```js /// + +// types for web /// +// types for React Native +/// + // top-level import import AlarmIcon from './icons/alarm.svg?react' diff --git a/packages/vite-plugin-reiconify/index.ts b/packages/vite-plugin-reiconify/index.ts index 4bf024a..c8af0fd 100644 --- a/packages/vite-plugin-reiconify/index.ts +++ b/packages/vite-plugin-reiconify/index.ts @@ -6,7 +6,10 @@ import transform from 'reiconify/lib/transform' * * @type {() => import('vite').Plugin} */ -export default function reiconify(pattern = /\.svg\?react$/) { +export default function reiconify({ + pattern = /\.svg\?react$/, + native = false, +} = {}) { return { name: 'vite-plugin-reiconify', enforce: 'pre', @@ -15,7 +18,10 @@ export default function reiconify(pattern = /\.svg\?react$/) { const [file] = id.split('?') const source = await (await fsp.readFile(file)).toString() return { - code: await transform(source, {baseName: 'base-icon'}), + code: await transform( + source, + native ? {native} : {baseName: 'base-icon'} + ), map: null, } } diff --git a/packages/vite-plugin-reiconify/native.d.ts b/packages/vite-plugin-reiconify/native.d.ts new file mode 100644 index 0000000..b603fd0 --- /dev/null +++ b/packages/vite-plugin-reiconify/native.d.ts @@ -0,0 +1,6 @@ +import type {SvgProps} from 'react-native-svg' + +declare module '*?react' { + const Icon: React.FC + export default Icon +} diff --git a/packages/vite-plugin-reiconify/package.json b/packages/vite-plugin-reiconify/package.json index bd1fb5f..778218b 100644 --- a/packages/vite-plugin-reiconify/package.json +++ b/packages/vite-plugin-reiconify/package.json @@ -12,6 +12,7 @@ "license": "MIT", "files": [ "client.d.ts", + "native.d.ts", "dist" ], "jest": { diff --git a/yarn.lock b/yarn.lock index cb10b50..357ca44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4205,6 +4205,11 @@ gray-matter@^4.0.2: section-matter "^1.0.0" strip-bom-string "^1.0.0" +harmony-reflect@^1.4.6: + version "1.6.2" + resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.2.tgz#31ecbd32e648a34d030d86adb67d4d47547fe710" + integrity sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g== + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -4313,6 +4318,13 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +identity-obj-proxy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz#94d2bda96084453ef36fbc5aaec37e0f79f1fc14" + integrity sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA== + dependencies: + harmony-reflect "^1.4.6" + import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"