diff --git a/.eslintrc b/.eslintrc index 9eaec07..c8052db 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,4 +1,9 @@ { "extends": "airbnb", - "root": true + "root": true, + "rules": { + "react/require-default-props": "off", + "react/jsx-filename-extension": "off", + "no-plusplus": "off" + } } diff --git a/.gitignore b/.gitignore index 996d68d..9178f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # gitignore node_modules lib +*.tgz # Only apps should have lockfiles npm-shrinkwrap.json diff --git a/.npmignore b/.npmignore deleted file mode 100644 index cd3ca40..0000000 --- a/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -src diff --git a/auto-id.js b/auto-id.js new file mode 100644 index 0000000..f9305ad --- /dev/null +++ b/auto-id.js @@ -0,0 +1,4 @@ +module.exports = { + AutoIdProvider: require('./lib/AutoIdProvider').default, + useId: require('./lib/useId').default, +}; diff --git a/package.json b/package.json index 220821c..2550f51 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,19 @@ "name": "babel-plugin-inline-react-svg", "version": "1.1.1", "description": "A babel plugin that optimizes and inlines SVGs for your react components.", + "files": [ + "lib", + "auto-id.js" + ], "main": "lib/index.js", + "sideEffects": false, "scripts": { "test": "npm run tests-only", "pretests-only": "npm run build", "tests-only": "babel-node test/sanity.js", - "build": "babel src --out-dir lib", + "build": "babel src --out-dir lib --verbose", "lint": "eslint src/", - "prepublish": "npm run build", + "prepare": "npm run build", "pretest": "npm run lint" }, "repository": { @@ -33,6 +38,7 @@ "@babel/cli": "^7.4.3", "@babel/core": "^7.0.0", "@babel/node": "^7.2.2", + "@babel/preset-env": "^7.8.4", "@babel/preset-react": "^7.0.0", "babel-preset-airbnb": "^3.2.1", "eslint": "^5.16.0", @@ -40,15 +46,17 @@ "eslint-plugin-import": "^2.16.0", "eslint-plugin-jsx-a11y": "^6.2.1", "eslint-plugin-react": "^7.12.4", - "react": "^15.3.1" + "react": "^16.8.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0", + "react": "^16.8.0" }, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/parser": "^7.0.0", "lodash.isplainobject": "^4.0.6", + "prop-types": "^15.7.2", "resolve": "^1.10.0", "svgo": "^0.7.2" } diff --git a/src/AutoIdProvider.js b/src/AutoIdProvider.js new file mode 100644 index 0000000..731a0cb --- /dev/null +++ b/src/AutoIdProvider.js @@ -0,0 +1,32 @@ +import React, { useCallback, useRef } from 'react'; +import PropTypes from 'prop-types'; + +let globalId = 0; + +// By default, if no AutoIdProvider is present, this `globalId` is the ID +// counter. This will work fine for browser-only apps. For SSR support, use +// AutoIdProvider, which will have a scoped counter per request. +const Context = React.createContext(() => `svg-id-${globalId++}`); + +/** + * Provide auto generated IDs for the `useId` hook, with support for a custom + * ID generator. Using this will ensure that each server-side request that + * renders your app has their own ID counter that starts at 0. + */ +export default function AutoIdProvider({ children, getId: customGetId }) { + const nextId = useRef(0); + + const getId = useCallback( + () => (customGetId ? customGetId(nextId) : `svg-id-${nextId.current++}`), + [customGetId], + ); + + return {children}; +} + +AutoIdProvider.Context = Context; + +AutoIdProvider.propTypes = { + children: PropTypes.node.isRequired, + getId: PropTypes.func, +}; diff --git a/src/index.js b/src/index.js index ed9a63f..284939a 100644 --- a/src/index.js +++ b/src/index.js @@ -25,23 +25,40 @@ export default declare(({ SVG_NAME, SVG_CODE, SVG_DEFAULT_PROPS_CODE, + NUM_IDS = 0, + HOOK_NAME, }) => { + const useIds = new Array(NUM_IDS).fill('HOOK_NAME()'); const namedTemplate = ` - var SVG_NAME = function SVG_NAME(props) { return SVG_CODE; }; + var SVG_NAME = function SVG_NAME(props) { + ${NUM_IDS ? `var ids = [${useIds.join(', ')}];` : ''} + return SVG_CODE; + }; ${SVG_DEFAULT_PROPS_CODE ? 'SVG_NAME.defaultProps = SVG_DEFAULT_PROPS_CODE;' : ''} ${IS_EXPORT ? 'export { SVG_NAME };' : ''} `; const anonymousTemplate = ` - var Component = function (props) { return SVG_CODE; }; + var Component = function (props) { + ${NUM_IDS ? `var ids = [${useIds.join(', ')}];` : ''} + return SVG_CODE; + }; ${SVG_DEFAULT_PROPS_CODE ? 'Component.defaultProps = SVG_DEFAULT_PROPS_CODE;' : ''} Component.displayName = 'EXPORT_FILENAME'; export default Component; `; if (SVG_NAME !== 'default') { - return template(namedTemplate)({ SVG_NAME, SVG_CODE, SVG_DEFAULT_PROPS_CODE }); + return template(namedTemplate)(NUM_IDS ? { + SVG_NAME, SVG_CODE, SVG_DEFAULT_PROPS_CODE, HOOK_NAME, + } : { + SVG_NAME, SVG_CODE, SVG_DEFAULT_PROPS_CODE, + }); } - return template(anonymousTemplate)({ SVG_CODE, SVG_DEFAULT_PROPS_CODE, EXPORT_FILENAME }); + return template(anonymousTemplate)(NUM_IDS ? { + SVG_CODE, SVG_DEFAULT_PROPS_CODE, EXPORT_FILENAME, HOOK_NAME, + } : { + SVG_CODE, SVG_DEFAULT_PROPS_CODE, EXPORT_FILENAME, + }); }; function applyPlugin(importIdentifier, importPath, path, state, isExport, exportFilename) { @@ -80,15 +97,25 @@ export default declare(({ plugins: ['jsx'], }); - traverse(parsedSvgAst, transformSvg(t)); + const ids = new Map(); + traverse(parsedSvgAst, transformSvg(t, { ids })); const svgCode = traverse.removeProperties(parsedSvgAst.program.body[0].expression); + file.get('ensureAutoId')(); + file.set('ensureAutoId', () => {}); + file.get('ensureReact')(); + file.set('ensureReact', () => {}); + + const hookIdentifier = path.scope.getBinding('useId').identifier; + const opts = { SVG_NAME: importIdentifier, SVG_CODE: svgCode, IS_EXPORT: isExport, EXPORT_FILENAME: exportFilename, + NUM_IDS: ids.size, + HOOK_NAME: hookIdentifier, }; // Move props off of element and into defaultProps @@ -115,8 +142,6 @@ export default declare(({ const svgReplacement = buildSvg(opts); path.replaceWith(svgReplacement); } - file.get('ensureReact')(); - file.set('ensureReact', () => {}); } } @@ -130,6 +155,22 @@ export default declare(({ if (typeof filename === 'undefined' && typeof opts.filename !== 'string') { throw new TypeError('the "filename" option is required when transforming code'); } + + if (!path.scope.hasBinding('useId')) { + const autoIdModule = opts.autoIdModule || 'babel-plugin-inline-react-svg/auto-id'; + + const autoIdImportDeclaration = t.importDeclaration([ + t.importSpecifier(t.identifier('useId'), t.identifier('useId')), + ], t.stringLiteral(autoIdModule)); + + file.set('ensureAutoId', () => { + const [newPath] = path.unshiftContainer('body', autoIdImportDeclaration); + newPath.get('specifiers').forEach((specifier) => { path.scope.registerBinding('module', specifier); }); + }); + } else { + file.set('ensureAutoId', () => {}); + } + if (!path.scope.hasBinding('React')) { const reactImportDeclaration = t.importDeclaration([ t.importDefaultSpecifier(t.identifier('React')), diff --git a/src/transformSvg.js b/src/transformSvg.js index fcc84cf..1bca415 100644 --- a/src/transformSvg.js +++ b/src/transformSvg.js @@ -6,8 +6,8 @@ import { namespaceToCamel, hyphenToCamel } from './camelize'; import cssToObj from './cssToObj'; -export default t => ({ - JSXAttribute({ node }) { +export default (t, state) => ({ + JSXAttribute({ node, parent }) { const { name: originalName } = node; if (t.isJSXNamespacedName(originalName)) { // converts @@ -40,6 +40,47 @@ export default t => ({ node.value = t.jSXExpressionContainer(t.objectExpression(properties)); } + if (originalName.name === 'id') { + // This plugin detects when props (including `id`) are on the root + // `` element and moves them to `defaultProps`. We don't want it + // to move the `ids[n]` expression there because it will break. Instead + // of messing with that behavior, let's just don't try to fiddle with + // `id` props on the root ``. + const isSvgId = Boolean( + parent + && parent.type === 'JSXOpeningElement' + && parent.name.name.toLowerCase() === 'svg', + ); + if (!isSvgId) { + const idToRewrite = node.value.value; + let index = state.ids.get(idToRewrite); + if (index == null) { + index = state.ids.size; + state.ids.set(idToRewrite, index); + } + node.value = t.jSXExpressionContainer( + t.memberExpression(t.identifier('ids'), t.numericLiteral(index), true), + ); + } + } else { + const idRefMatch = /^url\(['"]?#([^)]+)['"]?\)$/.exec(node.value.value); + if (idRefMatch) { + const idToRewrite = idRefMatch[1]; + let index = state.ids.get(idToRewrite); + if (index == null) { + index = state.ids.size; + state.ids.set(idToRewrite, index); + } + node.value = t.jSXExpressionContainer( + t.binaryExpression('+', + t.binaryExpression('+', + t.stringLiteral('url(#'), + t.memberExpression(t.identifier('ids'), t.numericLiteral(index), true)), + t.stringLiteral(')')), + ); + } + } + // converts // // to diff --git a/src/useId.js b/src/useId.js new file mode 100644 index 0000000..286dfa5 --- /dev/null +++ b/src/useId.js @@ -0,0 +1,8 @@ +import { useContext, useState } from 'react'; +import AutoIdProvider from './AutoIdProvider'; + +export default function useId(customId) { + const getId = useContext(AutoIdProvider.Context); + const [autoId] = useState(getId); + return customId || autoId; +} diff --git a/test/fixtures/ids.svg b/test/fixtures/ids.svg new file mode 100644 index 0000000..5e5eb0f --- /dev/null +++ b/test/fixtures/ids.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/test/fixtures/test-ids.jsx b/test/fixtures/test-ids.jsx new file mode 100644 index 0000000..549204e --- /dev/null +++ b/test/fixtures/test-ids.jsx @@ -0,0 +1,11 @@ +import MySvg from './ids.svg'; + +export default function MyFunctionIcon() { + return ; +} + +export class MyClassIcon extends React.Component { + render() { + return ; + } +} diff --git a/test/sanity.js b/test/sanity.js index b04db4c..8e53015 100644 --- a/test/sanity.js +++ b/test/sanity.js @@ -177,3 +177,14 @@ transformFile('test/fixtures/test-export-default-as.jsx', { if (err) throw err; console.log('test/fixtures/test-export-default-as.jsx', result.code); }); + +transformFile('test/fixtures/test-ids.jsx', { + babelrc: false, + presets: [['@babel/preset-env', { modules: 'commonjs' }], '@babel/preset-react'], + plugins: [ + inlineReactSvgPlugin, + ], +}, (err, result) => { + if (err) throw err; + console.log('test/fixtures/test-ids.jsx', result.code); +});