Skip to content

Commit

Permalink
codemod(svgs): Convert imported SVGs to react components (redwoodjs#8564
Browse files Browse the repository at this point in the history
)

Co-authored-by: Tobbe Lundberg <tobbe@tlundberg.com>
  • Loading branch information
dac09 and Tobbe committed Jun 26, 2023
1 parent 619977f commit c4ecba8
Show file tree
Hide file tree
Showing 32 changed files with 519 additions and 3 deletions.
3 changes: 3 additions & 0 deletions packages/codemods/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"@babel/traverse": "7.22.5",
"@iarna/toml": "2.2.5",
"@redwoodjs/project-config": "5.0.0",
"@svgr/core": "8.0.0",
"@svgr/plugin-jsx": "8.0.1",
"@vscode/ripgrep": "1.15.4",
"@whatwg-node/fetch": "0.9.7",
"cheerio": "1.0.0-rc.12",
Expand All @@ -40,6 +42,7 @@
"graphql": "16.6.0",
"jest": "29.5.0",
"jscodeshift": "0.15.0",
"pascalcase": "1.0.0",
"prettier": "2.8.8",
"tasuku": "2.0.1",
"typescript": "5.1.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Replace Component Svgs

This codemod will find all the cases where SVGs are imported as used as components, and then:
1. Generates a react component with SVGR (see fixtures for example)
2. Replaces the import to the svg file with an import to the new React component

e.g.

```diff
- import Bazinga from '../bazinga.svg'
+ import Bazinga from '../BazingaSVG.jsx'

const myComponent = () => {
// ...
<Bazinga/>
```
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Logo from '../mySvgs/Logo.svg'
import Kitten from '../mySvgs/kitten1.svg'

export default (props) => <>
<Logo {...props} />
<Kitten className="xxx-yyy bbb-aaa"/>
</>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const Kitten1 = props => <svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" {...props}><path fillRule="evenodd" d="M10 2v0c-.56 0-1 .44-1 1v1 0c0 .55.44 1 1 1 .55 0 1-.45 1-1V3v0c0-.56-.45-1-1-1ZM4 4h3v0c0 1.65 1.34 3 3 3 1.65 0 3-1.35 3-3h3v0c1.1 0 2 .89 2 2v9 0c0 1.1-.9 2-2 2H4v0c-1.11 0-2-.9-2-2V6v0c0-1.11.89-2 2-2Zm2.5 7v0c.82 0 1.5-.68 1.5-1.5C8 8.67 7.32 8 6.5 8v0C5.67 8 5 8.67 5 9.5c0 .82.67 1.5 1.5 1.5Zm2.45 4v0c.27-1.36-.6-2.68-1.96-2.95 -1.36-.28-2.68.59-2.95 1.95 -.07.32-.07.66 0 .99h4.9ZM12 9v0c-.56 0-1 .44-1 1 0 .55.44 1 1 1h3v0c.55 0 1-.45 1-1 0-.56-.45-1-1-1h-3Zm-1 4v0c0-.56.44-1 1-1h2v0c.55 0 1 .44 1 1 0 .55-.45 1-1 1h-2v0c-.56 0-1-.45-1-1Z" /></svg>;
export default Kitten1;
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const Logo = props => <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" {...props}><path fillRule="evenodd" d="M6.707 4.879A3 3 0 018.828 4H15a3 3 0 013 3v6a3 3 0 01-3 3H8.828a3 3 0 01-2.12-.879l-4.415-4.414a1 1 0 010-1.414l4.414-4.414zm4 2.414a1 1 0 00-1.414 1.414L10.586 10l-1.293 1.293a1 1 0 101.414 1.414L12 11.414l1.293 1.293a1 1 0 001.414-1.414L13.414 10l1.293-1.293a1 1 0 00-1.414-1.414L12 8.586l-1.293-1.293z" clipRule="evenodd" /></svg>;
export default Logo;
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Logo from "../mySvgs/LogoSVG"
import Kitten from "../mySvgs/Kitten1SVG"

export default (props) => <>
<Logo {...props} />
<Kitten className="xxx-yyy bbb-aaa"/>
</>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react'
import classNames from 'classnames'

import Button from '../Button/Button'

import Icon from './bazinga.svg'

const IconButton = ({
classes = '',
colours = 'text-white bg-black hover:bg-gray-800',
children,
}) => {
const buttonClasses = classNames(
'transform hover:scale-105',
classes,
colours
)

return (
<a
target="_blank"
referrerPolicy="origin"
rel="noreferrer"
>
<Button className={buttonClasses} icon={Icon}>
{children || 'Do something with a beautiful icon button'}
</Button>
</a>
)
}

export default IconButton
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import BazingaSvg from './bazinga.svg'

const MyComponent = () => {
return <BazingaSvg/>
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react'
import classNames from 'classnames'

import Button from '../Button/Button'

import Icon from "./BazingaSVG"

const IconButton = ({
classes = '',
colours = 'text-white bg-black hover:bg-gray-800',
children,
}) => {
const buttonClasses = classNames(
'transform hover:scale-105',
classes,
colours
)

return (
<a
target="_blank"
referrerPolicy="origin"
rel="noreferrer"
>
<Button className={buttonClasses} icon={Icon}>
{children || 'Do something with a beautiful icon button'}
</Button>
</a>
)
}

export default IconButton
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const Bazinga = props => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" {...props}><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" /></svg>;
export default Bazinga;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import BazingaSvg from "./BazingaSVG"

const MyComponent = () => {
return <BazingaSvg/>
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# This file contains the configuration settings for your Redwood app.
# This file is also what makes your Redwood app a Redwood app.
# If you remove it and try to run `yarn rw dev`, you'll get an error.
#
# For the full list of options, see the "App Configuration: redwood.toml" doc:
# https://redwoodjs.com/docs/app-configuration-redwood-toml

[web]
title = "Redwood App"
port = 8910
apiUrl = "/.redwood/functions" # you can customise graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths
includeEnvironmentVariables = [] # any ENV vars that should be available to the web side, see https://redwoodjs.com/docs/environment-variables#web
[api]
port = 8911
[browser]
open = true
[notifications]
versionUpdates = ["latest"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Logo from 'src/mySvgs/Logo.svg'
import Kitten from 'src/mySvgs/kitten1-with-dashes.svg'

export default (props) => <>
<Logo {...props} />
<Kitten className="xxx-yyy bbb-aaa"/>
</>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Logo from "src/mySvgs/LogoSVG"
import Kitten from "src/mySvgs/Kitten1WithDashesSVG"

export default (props) => <>
<Logo {...props} />
<Kitten className="xxx-yyy bbb-aaa"/>
</>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const Kitten1WithDashes = props => <svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" {...props}><path fillRule="evenodd" d="M10 2v0c-.56 0-1 .44-1 1v1 0c0 .55.44 1 1 1 .55 0 1-.45 1-1V3v0c0-.56-.45-1-1-1ZM4 4h3v0c0 1.65 1.34 3 3 3 1.65 0 3-1.35 3-3h3v0c1.1 0 2 .89 2 2v9 0c0 1.1-.9 2-2 2H4v0c-1.11 0-2-.9-2-2V6v0c0-1.11.89-2 2-2Zm2.5 7v0c.82 0 1.5-.68 1.5-1.5C8 8.67 7.32 8 6.5 8v0C5.67 8 5 8.67 5 9.5c0 .82.67 1.5 1.5 1.5Zm2.45 4v0c.27-1.36-.6-2.68-1.96-2.95 -1.36-.28-2.68.59-2.95 1.95 -.07.32-.07.66 0 .99h4.9ZM12 9v0c-.56 0-1 .44-1 1 0 .55.44 1 1 1h3v0c.55 0 1-.45 1-1 0-.56-.45-1-1-1h-3Zm-1 4v0c0-.56.44-1 1-1h2v0c.55 0 1 .44 1 1 0 .55-.45 1-1 1h-2v0c-.56 0-1-.45-1-1Z" /></svg>;
export default Kitten1WithDashes;
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const Logo = props => <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" {...props}><path fillRule="evenodd" d="M6.707 4.879A3 3 0 018.828 4H15a3 3 0 013 3v6a3 3 0 01-3 3H8.828a3 3 0 01-2.12-.879l-4.415-4.414a1 1 0 010-1.414l4.414-4.414zm4 2.414a1 1 0 00-1.414 1.414L10.586 10l-1.293 1.293a1 1 0 101.414 1.414L12 11.414l1.293 1.293a1 1 0 001.414-1.414L13.414 10l1.293-1.293a1 1 0 00-1.414-1.414L12 8.586l-1.293-1.293z" clipRule="evenodd" /></svg>;
export default Logo;
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
describe('replaceComponentSvgs', () => {
it('Handles simple Svgs as components', async () => {
await matchFolderTransform('replaceComponentSvgs', 'simple', {
useJsCodeshift: true,
targetPathsGlob: '**/*.{js,jsx,tsx}',
removeWhitespace: true, // needed for matching
})
})

it('Preserves attrs & deals with nesting', async () => {
await matchFolderTransform('replaceComponentSvgs', 'complex', {
useJsCodeshift: true,
targetPathsGlob: '**/*.{js,jsx,tsx}',
removeWhitespace: true, // needed for matching
})
})

it('Deals with src alias & casing in filenames', async () => {
await matchFolderTransform('replaceComponentSvgs', 'srcAlias', {
useJsCodeshift: true,
targetPathsGlob: '**/*.{js,jsx,tsx}',
removeWhitespace: true, // needed for matching
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import fs from 'fs/promises'
import path from 'path'

import { transform as svgrTransform } from '@svgr/core'
import type { API, FileInfo, StringLiteral } from 'jscodeshift'
import pascalcase from 'pascalcase'

async function convertSvgToReactComponent(
svgFilePath: string,
outputPath: string,
componentName: string
) {
const svgContents = await fs.readFile(svgFilePath, 'utf-8')

const jsCode = await svgrTransform(
svgContents,
{
jsxRuntime: 'automatic',
plugins: ['@svgr/plugin-jsx'],
},
{
componentName: componentName,
}
)

await fs.writeFile(outputPath, jsCode)

console.log(`SVG converted to React component: ${outputPath}`)
}

export default async function transform(file: FileInfo, api: API) {
const j = api.jscodeshift

const root = j(file.source)

// Do this lazily
const { getPaths } = await import('@redwoodjs/project-config')

// Find all import declarations with "*.svg" import
const svgImports = root.find(j.ImportDeclaration).filter((path) => {
const importPath = path.node.source.value as string
return importPath.includes('.svg')
})

const svgsToConvert: Array<{
filePath: string
importSourcePath: StringLiteral
}> = []
// Process each import declaration
svgImports.forEach((importDeclaration) => {
const importSpecifiers = importDeclaration.node.specifiers

// Process each import specifier
importSpecifiers?.forEach((importSpecifier) => {
if (importSpecifier.type === 'ImportDefaultSpecifier') {
if (!importSpecifier.local) {
// Un-freaking-likely, skip if it happens
return
}

const importName = importSpecifier.local.name

const importPath = importDeclaration.node.source.value as string
const currentFolder = path.dirname(file.path)

let pathToSvgFile = path.resolve(currentFolder, importPath)

if (importPath.startsWith('src/')) {
pathToSvgFile = importPath.replace('src/', getPaths().web.src + '/')
}

// Find the JSX elements that use the default import specifier
const svgsUsedAsComponent = root.findJSXElements(importName)

svgsUsedAsComponent.forEach(() => {
svgsToConvert.push({
filePath: pathToSvgFile,
importSourcePath: importDeclaration.node.source as StringLiteral, // imports are all strings in this case
})
})

const svgsUsedAsRenderProp = root.find(j.JSXExpressionContainer, {
expression: {
type: 'Identifier',
name: importName,
},
})

svgsUsedAsRenderProp.forEach(() => {
svgsToConvert.push({
filePath: pathToSvgFile,
importSourcePath: importDeclaration.node.source as StringLiteral, // imports are all strings in this case
})
})
}
})
})

if (svgsToConvert.length > 0) {
// if there are any svgs used as components, or render props, convert the svg to a react component
await Promise.all(
svgsToConvert.map(async ({ filePath, importSourcePath }) => {
const svgFileNameWithoutExtension = path.basename(
filePath,
path.extname(filePath)
)

const componentName = pascalcase(svgFileNameWithoutExtension)

const newFileName = `${componentName}SVG`

// The absolute path to the new file
const outputPath = path.join(
path.dirname(filePath),
`${newFileName}.jsx`
)

try {
await convertSvgToReactComponent(filePath, outputPath, componentName)
} catch (error: any) {
console.error(
`Error converting ${filePath} to React component: ${error.message}`
)

// Don't proceed if SVGr fails
return
}

// If SVGr is successful, change the import path
// '../../bazinga.svg' -> '../../BazingaSVG'
// Use extname, incase ext casing does not match
importSourcePath.value = importSourcePath.value.replace(
`${svgFileNameWithoutExtension}${path.extname(filePath)}`,
newFileName
)
})
)
}

return root.toSource()
}
Loading

0 comments on commit c4ecba8

Please sign in to comment.