@puns/shiftkit
v0.0.4
Published
jscodeshift helper library for React/React Native codemods
Maintainers
Readme
ShiftKit
TypeScript-first helper library for building jscodeshift codemods for React and React Native.
Structure
| File | Purpose |
|------|---------|
| imports.ts | Import statement manipulation (add, remove, redirect) |
| ast-builders.ts | AST node construction helpers (objects, arrays, members) |
| token-helpers.ts | Design token member expression builder |
| jsx/jsx-elements.ts | JSX element queries and creation |
| jsx/jsx-attributes.ts | JSX attribute manipulation |
| jsx/jsx-children.ts | JSX children manipulation |
| jsx/jsx-clone.ts | JSX element cloning utilities |
| jsx/jsx-conversions.ts | Type conversions (expressions ↔ literals ↔ attributes) |
| jsx/jsx-transforms.ts | High-level JSX transformations |
| jsx/extraction.ts | Value extraction with complexity detection |
| rn/stylesheet.ts | React Native StyleSheet helpers |
Installation
npm install @puns/shiftkit jscodeshiftBasic Usage
import { addNamedImport } from '@puns/shiftkit'
import { findJSXElements, updateElementName } from '@puns/shiftkit/jsx'
import type { API, FileInfo } from 'jscodeshift'
export default function transform(fileInfo: FileInfo, api: API) {
const j = api.jscodeshift
const root = j(fileInfo.source)
// Find and rename elements
findJSXElements(root, 'Box', j).forEach(path => {
updateElementName(path, 'View')
})
// Update imports
addNamedImport(root, 'react-native', 'View', j)
return root.toSource()
}Design Principles
- jQuery-inspired naming: intuitive method names (
find*,add*,remove*,clone*) - Type-safe: strict TypeScript with jscodeshift types
- Functional: stateless helpers, no classes
- Explicit: always pass
j(jscodeshift API) to functions - Safe: validates AST node types before operations
Import Management
Add Named Import
Adds import to existing statement or creates new one.
import { addNamedImport } from '@puns/shiftkit'
addNamedImport(root, 'react-native', 'View', j)
// Result: import { View } from 'react-native'
addNamedImport(root, 'react-native', 'Text', j)
// Result: import { View, Text } from 'react-native'Remove Named Import
Removes import specifier or entire statement if last one.
import { removeNamedImport } from '@puns/shiftkit'
const imports = root.find(j.ImportDeclaration, { source: { value: 'old-lib' } })
removeNamedImport(imports, 'OldComponent', j)Redirect Import
Changes import path while preserving type imports.
import { redirectImport, matchesImportPath } from '@puns/shiftkit'
root.find(j.ImportDeclaration)
.filter(path => matchesImportPath('@old/lib')(path.node.source.value))
.forEach(path => {
j(path).replaceWith(redirectImport(path.node, '@new/lib', j))
})Check Import Exists
import { hasNamedImport } from '@puns/shiftkit'
const reactImports = root.find(j.ImportDeclaration, { source: { value: 'react' } })
if (hasNamedImport(reactImports, 'useState')) {
// Transform logic
}JSX Element Operations
Find Elements
import { findJSXElements, findJSXMemberElements } from '@puns/shiftkit/jsx'
// Simple elements
findJSXElements(root, 'Button', j).forEach(path => {
// Transform each <Button />
})
// Member expressions
findJSXMemberElements(root, 'Alert', 'Title', j).forEach(path => {
// Transform each <Alert.Title />
})Create Elements
import {
createSelfClosingElement,
createElementWithChildren,
createMemberElement
} from '@puns/shiftkit/jsx'
// Self-closing
const icon = createSelfClosingElement('Icon', [
j.jsxAttribute(j.jsxIdentifier('name'), j.stringLiteral('check'))
], j)
// Result: <Icon name="check" />
// With children
const button = createElementWithChildren('Button', [], [
j.jsxText('Click me')
], j)
// Result: <Button>Click me</Button>
// Member expression
const title = createMemberElement('Card', 'Title', [], [
j.jsxText('Heading')
], j)
// Result: <Card.Title>Heading</Card.Title>Element Utilities
import { getElementName, hasChildren, getNonEmptyChildren } from '@puns/shiftkit/jsx'
const name = getElementName(element)
// Returns: 'Button' or 'Alert.Title'
const hasKids = hasChildren(element)
// Returns: boolean
const kids = getNonEmptyChildren(element)
// Returns: children excluding whitespace-only text nodesJSX Attributes
Find and Get Attributes
import {
findAttribute,
getAttribute,
getAttributeValue,
hasAttribute
} from '@puns/shiftkit/jsx'
const attrs = element.openingElement.attributes
// Find attribute node
const attr = findAttribute(attrs, 'variant')
// Get attribute value (wrapped)
const value = getAttribute(attrs, 'variant')
// Returns: JSXExpressionContainer or StringLiteral
// Get unwrapped value
const unwrapped = getAttributeValue(attrs, 'variant')
// Returns: Expression or Literal
// Check existence
if (hasAttribute(attrs, 'disabled')) {
// Handle disabled state
}Create Attributes
import {
createAttribute,
createStringAttribute,
createObjectAttribute
} from '@puns/shiftkit/jsx'
// Auto-wrapped
const attr1 = createAttribute('size', 'large', j)
// Result: size="large"
const attr2 = createAttribute('count', j.numericLiteral(5), j)
// Result: count={5}
// String literal
const attr3 = createStringAttribute('variant', 'primary', j)
// Result: variant="primary"
// Object expression
const attr4 = createObjectAttribute('style', { flex: j.numericLiteral(1) }, j)
// Result: style={{ flex: 1 }}Modify Attributes
import { setAttributes, addAttributes, removeAttributes } from '@puns/shiftkit/jsx'
// Replace all
setAttributes(element, [newAttr1, newAttr2])
// Add more
addAttributes(element, [newAttr3])
// Remove by name
removeAttributes(element.openingElement.attributes, ['disabled', 'hidden'])Filter Attributes
import { filterAttributes } from '@puns/shiftkit/jsx'
// Allow list
const allowed = filterAttributes(attrs, { allow: ['className', 'style'] })
// Deny list
const filtered = filterAttributes(attrs, { deny: ['onClick', 'onPress'] })
// Both
const result = filterAttributes(attrs, {
allow: ['className', 'id', 'style'],
deny: ['style'] // className and id only
})JSX Children
Add Children
import { addChildren } from '@puns/shiftkit/jsx'
addChildren(element, [newChild1, newChild2], 'end')
addChildren(element, [newChild], 'start')Replace and Remove
import { replaceChildren, removeChildren } from '@puns/shiftkit/jsx'
replaceChildren(element, [newChild1, newChild2])
// Remove by predicate
const removed = removeChildren(element, child =>
child.type === 'JSXElement' && getElementName(child) === 'Icon'
)Wrap Children
import { wrapChildren } from '@puns/shiftkit/jsx'
wrapChildren(element, 'View', j)
// Before: <Parent><Child /></Parent>
// After: <Parent><View><Child /></View></Parent>Query Children
import {
findChild,
findChildByName,
getChildrenCount,
getTextContent
} from '@puns/shiftkit/jsx'
// Find by predicate
const child = findChild(element, c => c.type === 'JSXElement')
// Find by element name
const icon = findChildByName(element, 'Icon')
// Count non-whitespace children
const count = getChildrenCount(element)
// Extract all text
const text = getTextContent(element)
// Returns: 'Hello World' from <div>Hello <span>World</span></div>Insert and Remove at Index
import { insertChildAt, removeChildAt } from '@puns/shiftkit/jsx'
insertChildAt(element, newChild, 2)
const removed = removeChildAt(element, 1)JSX Cloning
Basic Cloning
import { cloneElement, cloneWithNewName } from '@puns/shiftkit/jsx'
// Deep clone
const copy = cloneElement(element, j)
// Clone with new name
const view = cloneWithNewName(buttonElement, 'View', j)
// <Button /> → <View />Clone with Modifications
import {
cloneWithAttributes,
cloneWithAddedAttributes,
cloneWithChildren,
cloneAsSelfClosing
} from '@puns/shiftkit/jsx'
// Replace attributes
const el1 = cloneWithAttributes(element, [newAttr1, newAttr2], j)
// Add attributes
const el2 = cloneWithAddedAttributes(element, [extraAttr], j)
// Replace children
const el3 = cloneWithChildren(element, [newChild], j)
// Make self-closing
const el4 = cloneAsSelfClosing(element, j)
// <Button>Text</Button> → <Button />Transform While Cloning
import {
cloneWithTransformedAttributes,
cloneWithTransformedChildren
} from '@puns/shiftkit/jsx'
// Transform attributes
const el1 = cloneWithTransformedAttributes(element, attrs => {
return attrs.filter(attr => attr.name.name !== 'deprecated')
}, j)
// Transform children
const el2 = cloneWithTransformedChildren(element, children => {
return children.map(child => /* transform */)
}, j)Type Conversions
Expression Conversions
import {
stringToExpression,
expressionToString,
ensureExpression,
ensureLiteral
} from '@puns/shiftkit/jsx'
// String to identifier expression
const expr = stringToExpression('myVar', j)
// Result: {myVar}
// Extract string from expression
const str = expressionToString(jsxExprContainer)
// Returns: 'value' or null
// Ensure proper wrapping
const wrapped = ensureExpression(value, j)
// Handles literals, expressions, primitives
// Ensure literal node
const literal = ensureLiteral(42, j)
// Result: NumericLiteralAttribute Value Conversions
import { toAttributeValue, fromAttributeValue } from '@puns/shiftkit/jsx'
// To JSX attribute format
const attrVal1 = toAttributeValue('text', j)
// Result: StringLiteral
const attrVal2 = toAttributeValue(42, j)
// Result: JSXExpressionContainer(NumericLiteral(42))
// Extract raw value
const raw = fromAttributeValue(attrValue)
// Returns: primitive value or AST nodeBoolean Attributes
import { toBooleanAttribute } from '@puns/shiftkit/jsx'
const attr1 = toBooleanAttribute(true, j)
// Result: null (JSX uses <Component enabled />)
const attr2 = toBooleanAttribute(false, j)
// Result: JSXExpressionContainer(BooleanLiteral(false))Style Objects
import { toStyleObject } from '@puns/shiftkit/jsx'
const styleObj = toStyleObject({ flex: 1, padding: 16 }, j)
// Result: ObjectExpression { flex: 1, padding: 16 }Numeric Parsing
import { parseNumeric, stringToNumeric } from '@puns/shiftkit/jsx'
const num = parseNumeric('42')
// Returns: 42
const literal = stringToNumeric('42', j)
// Result: NumericLiteral(42)High-Level Transforms
Update Element Name
import { updateElementName } from '@puns/shiftkit/jsx'
findJSXElements(root, 'Box', j).forEach(path => {
updateElementName(path, 'View')
})
// <Box /> → <View />Manage Props
import { removePropsFromElement, addPropsToElement } from '@puns/shiftkit/jsx'
const attrs = element.openingElement.attributes
const toRemove = attrs.filter(attr => attr.name.name === 'deprecated')
removePropsFromElement(attrs, toRemove)
addPropsToElement(attrs, {
variant: j.stringLiteral('new'),
size: j.numericLiteral(16)
}, j)Style Prop Building
import { buildStyleValue, addStyleProp } from '@puns/shiftkit/jsx'
const elementStyles = []
const styleValue = buildStyleValue(
{ flex: j.numericLiteral(1) }, // StyleSheet props
{ margin: j.numericLiteral(8) }, // Inline props
'container', // StyleSheet entry name
elementStyles, // Accumulator
j,
[existingStyleRef] // Existing refs
)
addStyleProp(element.openingElement.attributes, styleValue, j)
// Result: style={[existingStyleRef, styles.container, { margin: 8 }]}Create View Wrapper
import { createViewWrapper } from '@puns/shiftkit/jsx'
const wrapped = createViewWrapper(
childElement,
j.memberExpression(j.identifier('styles'), j.identifier('wrapper')),
j
)
// Result: <View style={styles.wrapper}>{child}</View>Value Extraction
Extract Prop from JSX Element
import { extractPropFromJSXElement } from '@puns/shiftkit/jsx'
const iconName = extractPropFromJSXElement(iconElement, 'Icon', 'name')
// From: <Icon name="check" color="blue" />
// Returns: 'check' (string literal value)
const dynamicValue = extractPropFromJSXElement(element, 'Button', 'onPress')
// From: <Button onPress={handlePress} />
// Returns: Identifier AST node for 'handlePress'Extract Simple Child
Extracts single child with complexity detection.
import { extractSimpleChild } from '@puns/shiftkit/jsx'
const result = extractSimpleChild(element.children, j, {
allowedExpressionTypes: ['Identifier', 'MemberExpression', 'CallExpression']
})
if (result.isComplex) {
// Has multiple children or JSX elements
console.log('Cannot extract, too complex')
} else if (result.value) {
// Single simple value extracted
const newAttr = j.jsxAttribute(j.jsxIdentifier('label'), result.value)
}| Option | Type | Description |
|--------|------|-------------|
| allowedExpressionTypes | string[] | Allowed AST node types for expressions |
Default allowed types: ['Identifier', 'CallExpression', 'MemberExpression', 'StringLiteral']
AST Builders
Object Expression
import { createObjectExpression } from '@puns/shiftkit/jsx'
const obj = createObjectExpression({
name: j.identifier('userName'),
age: '25', // Auto-converted to StringLiteral
active: true // Need to pass j.booleanLiteral(true) for proper handling
}, j)
// Result: { name: userName, age: '25', active: true }Member Expression
import { createMemberExpression } from '@puns/shiftkit/jsx'
const member1 = createMemberExpression('styles.button', j)
const member2 = createMemberExpression(['styles', 'button'], j)
// Both result in: styles.button
const member3 = createMemberExpression('theme.colors.primary', j)
// Result: theme.colors.primaryArray Expression
import { createArrayExpression } from '@puns/shiftkit/jsx'
const arr = createArrayExpression(['a', 'b', j.identifier('c')], j)
// Result: ['a', 'b', c]Object Property
import { createProperty } from '@puns/shiftkit/jsx'
const prop = createProperty('margin', 16, j)
// Result: margin: 16Nested Object
Recursively handles nested structures.
import { createNestedObject } from '@puns/shiftkit/jsx'
const nested = createNestedObject({
image: {
source: {
uri: j.identifier('imageUri')
}
}
}, j)
// Result: { image: { source: { uri: imageUri } } }Template Literal
import { createTemplateLiteral } from '@puns/shiftkit/jsx'
const template = createTemplateLiteral([
'Hello ',
j.identifier('name'),
'!'
], j)
// Result: `Hello ${name}!`Design Tokens
Build Token Path
Handles numeric keys with bracket notation.
import { buildTokenPath } from '@puns/shiftkit'
const token1 = buildTokenPath(j, 'color', 'background.primary')
// Result: color.background.primary
const token2 = buildTokenPath(j, 'color', 'blue.500')
// Result: color.blue['500']
const token3 = buildTokenPath(j, 'space', 'md')
// Result: space.mdRules for bracket notation:
- Starts with digit:
['500'] - Starts with dash:
['-1'] - Not valid identifier:
['my-prop']
React Native StyleSheet
Extract to StyleSheet
import { shouldExtractToStyleSheet } from '@puns/shiftkit/rn'
const canExtract = shouldExtractToStyleSheet(value, isTokenHelper)
// Returns true for: literals, token helpers (if flag set)
// Returns false for: variables, function calls, user member expressionsBuild StyleSheet Properties
import { buildStyleSheetProperties } from '@puns/shiftkit/rn'
const props = buildStyleSheetProperties({
flex: j.numericLiteral(1),
padding: j.numericLiteral(16)
}, j)
// Result: [Property, Property]Add or Extend StyleSheet
Appends to existing StyleSheet.create() or creates new one.
import { addOrExtendStyleSheet } from '@puns/shiftkit/rn'
const elementStyles = [
{ name: 'container', styles: { flex: j.numericLiteral(1) } },
{ name: 'text', styles: { fontSize: j.numericLiteral(16) } }
]
addOrExtendStyleSheet(root, elementStyles, j)
// Result: const styles = StyleSheet.create({ container: {...}, text: {...} })
// Also adds: import { StyleSheet } from 'react-native'Real-World Example: Migrate Component
import { addNamedImport, removeNamedImport } from '@puns/shiftkit'
import {
findJSXElements,
updateElementName,
cloneWithTransformedAttributes
} from '@puns/shiftkit/jsx'
import type { API, FileInfo } from 'jscodeshift'
export default function transform(fileInfo: FileInfo, api: API) {
const j = api.jscodeshift
const root = j(fileInfo.source)
// Find old component
findJSXElements(root, 'OldButton', j).forEach(path => {
// Transform attributes
const newElement = cloneWithTransformedAttributes(path.node, attrs => {
return attrs.map(attr => {
if (attr.name.name === 'variant') {
// Rename prop
return j.jsxAttribute(j.jsxIdentifier('appearance'), attr.value)
}
return attr
}).filter(attr => attr.name.name !== 'deprecated')
}, j)
// Update name
path.node.openingElement.name.name = 'NewButton'
if (path.node.closingElement) {
path.node.closingElement.name.name = 'NewButton'
}
path.node.openingElement.attributes = newElement.openingElement.attributes
})
// Update imports
const oldImports = root.find(j.ImportDeclaration, { source: { value: 'old-lib' } })
removeNamedImport(oldImports, 'OldButton', j)
addNamedImport(root, 'new-lib', 'NewButton', j)
return root.toSource()
}Testing
# Type check
npm run typecheck
# Build
npm run build
# Test specific codemod
npx jscodeshift -t my-transform.ts src/**/*.tsx --dry --printDo's and Don'ts
Do:
- Always pass
j(jscodeshift API) to helper functions - Check node types before operations
- Use
--dry --printflags when testing transforms - Handle both
JSXIdentifierandJSXMemberExpressionfor element names - Filter whitespace-only text nodes when counting children
Don't:
- Don't mutate AST nodes directly without cloning
- Don't assume attributes array exists (check
|| []) - Don't forget to update both opening and closing element names
- Don't skip adding imports after element renames
- Don't use bracket notation for valid identifier keys
Performance Notes
| Operation | Complexity | Notes |
|-----------|------------|-------|
| findJSXElements | O(n) | Scans entire AST |
| addNamedImport | O(n) | Searches existing imports |
| cloneElement | O(1) | Shallow clone |
| extractSimpleChild | O(n) | Filters children array |
| buildTokenPath | O(d) | d = token path depth |
Module Dependencies
jscodeshift(peer): AST manipulation@types/jscodeshift(dev): Type definitions
No runtime dependencies.
License
MIT
