diff --git a/example/babel.config.js b/example/babel.config.js index 3d64221..f92d429 100644 --- a/example/babel.config.js +++ b/example/babel.config.js @@ -9,8 +9,8 @@ module.exports = function (api) { return getConfig( { - presets: ["babel-preset-expo", `module:${root}/dist/commonjs/babel`], - // presets: ["babel-preset-expo"], + // presets: ["babel-preset-expo", `module:${root}/dist/commonjs/babel`], + presets: ["babel-preset-expo"], }, { root, pkg }, ); diff --git a/example/global.css b/example/global.css index 03c53de..9cbd4d6 100644 --- a/example/global.css +++ b/example/global.css @@ -5,5 +5,5 @@ } .text-red-500 { - color: red; + color: red } \ No newline at end of file diff --git a/example/src/App.tsx b/example/src/App.tsx index 5a7c89b..509aa4b 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -3,9 +3,10 @@ import { Text, View } from "react-native"; import "../global.css"; export default function App() { + console.log("App component rendered"); return ( - Hello World + Hello World ); } diff --git a/src/__tests__/babel/react-native-web.test.ts b/src/__tests__/babel/react-native-web.test.ts index 612d529..5d54a4d 100644 --- a/src/__tests__/babel/react-native-web.test.ts +++ b/src/__tests__/babel/react-native-web.test.ts @@ -14,27 +14,81 @@ describe("react-native-web", () => { plugins: ["@babel/plugin-syntax-jsx"], }, tests: appendTitles([ + /* import tests */ { code: `import 'react-native-web';`, output: `import "react-native-css/components";`, }, + { + code: `import ReactNativeWeb from 'react-native-web';`, + output: `import ReactNativeWeb from "react-native-css/components";`, + }, { code: `import { View } from 'react-native-web';`, output: `import { View } from "react-native-css/components/View";`, }, { - code: `import View from 'react-native-web/dist/commonjs/exports/View';`, + code: `import View from 'react-native-web/dist/cjs/View';`, output: `import { View } from "react-native-css/components/View";`, }, { - code: `import View from 'react-native-web/dist/module/exports/View';`, + code: `import View from 'react-native-web/dist/modules/View';`, output: `import { View } from "react-native-css/components/View";`, }, { code: `import View from '../View';`, output: `import { View } from "react-native-css/components/View";`, babelOptions: { - filename: "react-native-web/dist/module/exports/ScrollView/index.js", + filename: "react-native-web/dist/modules/ScrollView/index.js", + }, + }, + + /* require() tests */ + { + code: `const { Text } = require('react-native-web');`, + output: `const { Text } = require("react-native-css/components/Text");`, + }, + { + code: `const Text = require('react-native-web/dist/modules/Text');`, + output: `const { Text } = require("react-native-css/components/Text");`, + }, + { + code: `const _Text = require('react-native-web/dist/modules/Text');`, + output: `const { Text: _Text } = require("react-native-css/components/Text");`, + }, + { + code: `const Text = require('react-native-web/dist/cjs/Text');`, + output: `const { Text } = require("react-native-css/components/Text");`, + }, + { + code: `const _Text = require('react-native-web/dist/cjs/Text');`, + output: `const { Text: _Text } = require("react-native-css/components/Text");`, + }, + { + code: `const Text = require('react-native-web/dist/exports/Text');`, + output: `const { Text } = require("react-native-css/components/Text");`, + }, + { + code: `const _Text = require('react-native-web/dist/exports/Text');`, + output: `const { Text: _Text } = require("react-native-css/components/Text");`, + }, + { + code: `const _Text = _interopRequireDefault(require('react-native-web/dist/Text'));`, + output: `const { Text: _Text } = require("react-native-css/components/Text");`, + }, + { + code: `const _Text = _interopRequireDefault(require('react-native-web/dist/modules/Text'));`, + output: `const { Text: _Text } = require("react-native-css/components/Text");`, + }, + { + code: `const _Text = _interopRequireDefault(require('react-native-web/dist/cjs/Text'));`, + output: `const { Text: _Text } = require("react-native-css/components/Text");`, + }, + { + code: `const View = _interopRequireDefault(require('../View'));`, + output: `const { View } = require("react-native-css/components/View");`, + babelOptions: { + filename: "react-native-web/dist/modules/ScrollView/index.js", }, }, ]), diff --git a/src/__tests__/babel/smoke.test.ts b/src/__tests__/babel/smoke.test.ts index 2913491..ad5c202 100644 --- a/src/__tests__/babel/smoke.test.ts +++ b/src/__tests__/babel/smoke.test.ts @@ -15,28 +15,10 @@ describe("plugin smoke tests", () => { filename: "/someFile.js", }, tests: appendTitles([ - { - code: `import 'react-native';`, - output: `import "react-native-css/components";`, - }, { code: `import '../global.css';`, output: `import "../global.css";`, }, - { - code: `import { View } from 'react-native';`, - output: `import { View } from "react-native";`, - babelOptions: { - filename: "/node_modules/react-native-css/components/View.js", - }, - }, - { - code: `import { View } from 'react-native';`, - output: `import { View } from "react-native";`, - babelOptions: { - filename: "react-native-css/src/components/View.js", - }, - }, { code: `import * as NativeComponentRegistry from '../../NativeComponent/NativeComponentRegistry'`, output: `import * as NativeComponentRegistry from "../../NativeComponent/NativeComponentRegistry";`, diff --git a/src/babel/allowedModules.ts b/src/babel/allowedModules.ts new file mode 100644 index 0000000..212c2c7 --- /dev/null +++ b/src/babel/allowedModules.ts @@ -0,0 +1,24 @@ +import { readdirSync } from "fs"; +import { join, parse } from "path"; + +function getFilesWithoutExtension(dirPath: string) { + // Read all files and directories inside dirPath synchronously + const entries = readdirSync(dirPath, { withFileTypes: true }); + + // Filter only files (ignore directories) + const files = entries.filter( + (entry) => + entry.isFile() && + /\.[jt]sx?$/.exec(entry.name) && + !/index\.[jt]sx?$/.exec(entry.name), + ); + + // For each file, get the filename without extension + const filesWithoutExt = files.map((file) => parse(file.name).name); + + return filesWithoutExt; +} + +export const allowedModules = new Set( + getFilesWithoutExtension(join(__dirname, "../components")), +); diff --git a/src/babel/helpers.ts b/src/babel/helpers.ts index e9ea674..c66f630 100644 --- a/src/babel/helpers.ts +++ b/src/babel/helpers.ts @@ -1,11 +1,6 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { readdirSync } from "fs"; -import { dirname, join, normalize, parse, resolve, sep } from "path"; -import { type NodePath } from "@babel/traverse"; +import tBabelTypes, { type CallExpression } from "@babel/types"; -import t from "@babel/types"; - -export type BabelTypes = typeof t; +export type BabelTypes = typeof tBabelTypes; export interface PluginOpts { target?: string; @@ -18,165 +13,28 @@ export interface PluginState { filename: string; } -export const allowedModules = new Set( - getFilesWithoutExtension(join(__dirname, "../components")), -); - -function getFilesWithoutExtension(dirPath: string) { - // Read all files and directories inside dirPath synchronously - const entries = readdirSync(dirPath, { withFileTypes: true }); - - // Filter only files (ignore directories) - const files = entries.filter( - (entry) => - entry.isFile() && - /\.[jt]sx?$/.exec(entry.name) && - !/index\.[jt]sx?$/.exec(entry.name), - ); - - // For each file, get the filename without extension - const filesWithoutExt = files.map((file) => parse(file.name).name); - - return filesWithoutExt; -} - -export function isInsideModule(filename: string, module: string): boolean { - const normalized = normalize(filename); - - // Match exact module name - if (normalized === module) { - return true; - } - - // Absolute posix paths - if (normalized.startsWith(`${module}/`)) { - return true; +export function getInteropRequireDefaultSource( + init: CallExpression, + t: BabelTypes, +) { + if (!t.isIdentifier(init.callee, { name: "_interopRequireDefault" })) { + return; } - // Check for our local development structure - if (normalized.includes(`${sep}${module}${sep}src${sep}`)) { - // Ignore the test files - return !normalized.includes("__tests__"); - } + const interopArg = init.arguments.at(0); - // Match classic node_modules - if (normalized.includes(`${sep}node_modules${sep}${module}${sep}`)) { - return true; - } - - // Match Yarn PnP .zip-style paths (e.g., .zip/node_modules/${module}/) if ( - normalized.includes(`${sep}.zip${sep}node_modules${sep}${module}${sep}`) + !t.isCallExpression(interopArg) || + !t.isIdentifier(interopArg.callee, { name: "require" }) ) { - return true; - } - - // Match Yarn .yarn/cache/${module}/* - if (normalized.includes(`${sep}.yarn${sep}cache${sep}${module}${sep}`)) { - return true; + return; } - return false; -} - -export function isPackageImport( - path: NodePath, -) { - const source = path.node.source?.value; - if (!source) { - return false; - } - - return source === "react-native" || source === "react-native-web"; -} - -export function shouldTransformImport( - path: NodePath, - filename: string, -) { - let source = path.node.source?.value; - - if (!source) { - return false; - } - - if (source.startsWith(".")) { - source = resolve(dirname(filename), source); - } - - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return isReactNativeSource(source) || isReactNativeWebSource(source); -} - -function isReactNativeSource(source: string) { - if (source === "react-native") return true; - - const components = source.split( - `react-native${sep}Libraries${sep}Components${sep}`, - ); - - if (components.length > 1) { - const component = components[1]?.split(sep)[0]; - return component && allowedModules.has(component); - } - - return false; -} - -function isReactNativeWebSource(source: string) { - if (source === "react-native-web") return true; - - let components = source.split( - `react-native-web${sep}dist${sep}commonjs${sep}exports${sep}`, - ); - if (components.length > 1) { - const component = components[1]?.split(sep)[0]; - return component && allowedModules.has(component); - } - - components = source.split( - `react-native-web${sep}dist${sep}module${sep}exports${sep}`, - ); - if (components.length > 1) { - const component = components[1]?.split(sep)[0]; - return component && allowedModules.has(component); - } - - return false; -} - -export function shouldTransformRequire( - t: BabelTypes, - node: t.VariableDeclaration, - basePath: string, -) { - const { declarations } = node; - - const declaration = declarations[0]; - - if (declarations.length > 1 || !declaration) { - return false; - } - const { id, init } = declaration; - - let source = - (t.isObjectPattern(id) || t.isIdentifier(id)) && - t.isCallExpression(init) && - t.isIdentifier(init.callee) && - init.callee.name === "require" && - init.arguments.length === 1 && - "value" in init.arguments[0]! && - typeof init.arguments[0].value === "string" && - init.arguments[0].value; - - if (!source) { - return false; - } + const requireArg = interopArg.arguments.at(0); - if (source.startsWith(".")) { - source = resolve(basePath, source); + if (!t.isStringLiteral(requireArg)) { + return; } - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return isReactNativeSource(source) || isReactNativeWebSource(source); + return requireArg.value; } diff --git a/src/babel/import-plugin.ts b/src/babel/import-plugin.ts index f73aebe..9e70df3 100644 --- a/src/babel/import-plugin.ts +++ b/src/babel/import-plugin.ts @@ -1,14 +1,22 @@ import { type PluginObj } from "@babel/core"; +import { resolve } from "path"; import { type BabelTypes, type PluginState, - allowedModules, - shouldTransformImport, - isInsideModule, - isPackageImport, - shouldTransformRequire, + getInteropRequireDefaultSource, } from "./helpers"; +import { + handleReactNativeWebIdentifierRequire, + handleReactNativeWebImport, + handleReactNativeWebObjectPatternRequire, +} from "./react-native-web"; +import type { Statement } from "@babel/types"; +import { + handleReactNativeIdentifierRequire, + handleReactNativeImport, + handleReactNativeObjectPatternRequire, +} from "./react-native"; export default function ({ types: t, @@ -17,166 +25,150 @@ export default function ({ }): PluginObj { const processed = new WeakSet(); - function importDeclaration(...args: Parameters) { - const declaration = t.importDeclaration(...args); - processed.add(declaration); - return declaration; - } + const thisModuleDist = resolve(__dirname, "../../../dist"); + const thisModuleSrc = resolve(__dirname, "../../../src"); - function requireDeclaration( - ...args: Parameters - ) { - const declaration = t.variableDeclaration(...args); - processed.add(declaration); - return declaration; + function isFromThisModule(filename: string): boolean { + return ( + filename.startsWith(thisModuleDist) || filename.startsWith(thisModuleSrc) + ); } return { name: "Rewrite react-native to react-native-css", visitor: { ImportDeclaration(path, state): void { - if (processed.has(path) || processed.has(path.node)) { + if ( + processed.has(path) || + processed.has(path.node) || + isFromThisModule(state.filename) + ) { return; } - const { specifiers, source } = path.node; + const statements = + handleReactNativeImport(path.node, t, state.filename) ?? + handleReactNativeWebImport(path.node, t, state.filename); + + if (!statements) { + return; + } + + for (const statement of statements) { + processed.add(statement); + } + path.replaceWithMultiple(statements); + }, + VariableDeclaration(path, state): void { if ( - isInsideModule(state.filename, "react-native-css") || - !shouldTransformImport(path, state.filename) + processed.has(path) || + processed.has(path.node) || + isFromThisModule(state.filename) ) { return; } - if (specifiers.length === 0) { - path.replaceWith( - importDeclaration( - [], - t.stringLiteral("react-native-css/components"), - ), - ); - path.scope.registerDeclaration(path); + const firstDeclaration = path.node.declarations.at(0); + + // We only handle single variable declarations for now. + if (path.node.declarations.length > 1) { return; } - const imports = specifiers.map((specifier) => { - if (t.isImportDefaultSpecifier(specifier)) { - const name = specifier.local.name; - - if (isPackageImport(path)) { - return importDeclaration( - [t.importDefaultSpecifier(specifier.local)], - t.stringLiteral("react-native-css/components"), - ); - } else if (allowedModules.has(name)) { - return importDeclaration( - [t.importSpecifier(specifier.local, specifier.local)], - t.stringLiteral(`react-native-css/components/${name}`), - ); - } else { - return importDeclaration([specifier], source); - } - } else if (t.isImportNamespaceSpecifier(specifier)) { - return importDeclaration( - [specifier], - t.stringLiteral("react-native-css/components"), - ); - } else { - const localName = t.isStringLiteral(specifier.imported) - ? specifier.imported.value - : specifier.imported.name; - - return allowedModules.has(localName) - ? importDeclaration( - [t.importSpecifier(specifier.local, specifier.imported)], - t.stringLiteral(`react-native-css/components/${localName}`), - ) - : importDeclaration([specifier], source); - } - }); + // Skip declarations that are not initialized. eg `let x;` + if (!firstDeclaration?.init) { + return; + } - path.replaceInline(imports).map((importPath) => { - path.scope.registerDeclaration(importPath); - }); - }, - VariableDeclaration(path, state): void { - if (processed.has(path) || processed.has(path.node)) { + const { id, init } = firstDeclaration; + + // We only handle `const = ()` + if (!t.isCallExpression(init)) { return; } - if ( - isInsideModule(state.filename, "react-native-css") || - !shouldTransformRequire(t, path.node, state.filename) - ) { + // We only handle `const id = () OR const { } = ()` + if (!(t.isIdentifier(id) || t.isObjectPattern(id))) { return; } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { id, init } = path.node.declarations[0]!; - - if (!t.isObjectPattern(id) || !init) { - path.replaceWith( - requireDeclaration(path.node.kind, [ - t.variableDeclarator( - id, - t.callExpression(t.identifier("require"), [ - t.stringLiteral(`react-native-css/components`), - ]), - ), - ]), - ); + const initArg = init.arguments.at(0); + + if (!initArg) { return; } - const imports = id.properties.map((identifier) => { - if (t.isRestElement(identifier)) { - return requireDeclaration(path.node.kind, [ - t.variableDeclarator(identifier, init), - ]); - } + let statements: Statement[] | undefined; - if (!t.isIdentifier(identifier.key)) { - return requireDeclaration(path.node.kind, [ - t.variableDeclarator(t.objectPattern([identifier]), init), - ]); + // `const = require();` + if ( + t.isIdentifier(id) && + t.isIdentifier(init.callee, { name: "require" }) && + t.isStringLiteral(initArg) + ) { + statements = + handleReactNativeIdentifierRequire( + path, + t, + id.name, + initArg.value, + state.filename, + ) ?? + handleReactNativeWebIdentifierRequire( + path, + t, + id.name, + initArg.value, + state.filename, + ); + } else if ( + t.isObjectPattern(id) && + t.isIdentifier(init.callee, { name: "require" }) && + t.isStringLiteral(initArg) + ) { + statements = + handleReactNativeObjectPatternRequire( + path, + t, + id, + initArg.value, + state.filename, + ) ?? + handleReactNativeWebObjectPatternRequire( + path, + t, + id, + initArg.value, + state.filename, + ); + } else if ( + t.isIdentifier(id) && + t.isIdentifier(init.callee, { name: "_interopRequireDefault" }) + ) { + // `const = _interopRequireDefault(require());` + const source = getInteropRequireDefaultSource(init, t); + if (!source) { + return; } + statements = handleReactNativeWebIdentifierRequire( + path, + t, + id.name, + source, + state.filename, + ); + } - const name = identifier.key.name; - - if (!allowedModules.has(name)) { - return requireDeclaration(path.node.kind, [ - t.variableDeclarator( - t.objectPattern([ - t.objectProperty( - t.identifier(name), - t.identifier(name), - false, - true, - ), - ]), - init, - ), - ]); - } + if (!statements) { + return; + } + + for (const statement of statements) { + processed.add(statement); + } - return requireDeclaration(path.node.kind, [ - t.variableDeclarator( - t.objectPattern([ - t.objectProperty( - t.identifier(name), - t.identifier(name), - false, - true, - ), - ]), - t.callExpression(t.identifier("require"), [ - t.stringLiteral(`react-native-css/components/${name}`), - ]), - ), - ]); - }); - - path.replaceWithMultiple(imports); + path.replaceWithMultiple(statements); }, }, }; diff --git a/src/babel/react-native-web.ts b/src/babel/react-native-web.ts new file mode 100644 index 0000000..0029d83 --- /dev/null +++ b/src/babel/react-native-web.ts @@ -0,0 +1,225 @@ +import { type NodePath } from "@babel/traverse"; + +import tBabelTypes, { + type ImportDeclaration, + type ObjectPattern, + type Statement, + type VariableDeclaration, +} from "@babel/types"; +import { allowedModules } from "./allowedModules"; +import { resolve } from "path"; + +type BabelTypes = typeof tBabelTypes; + +function parseReactNativeWebSource(source: string, filename: string) { + if (source.startsWith(".")) { + source = resolve(filename, source); + + const internalPath = source.split("react-native-web/dist")[1]; + if (!internalPath) { + return; + } + + // Strip the absolute system filepath + source = `react-native-web/${internalPath}`; + } + + if (source === "react-native-web") { + return { source: "react-native-css/components" }; + } else if (source.startsWith("react-native-web/")) { + const name = source.split("/").at(-1); + if (!name || !allowedModules.has(name)) { + return; + } + + return { + source: `react-native-css/components/${name}`, + name, + }; + } + + return; +} + +export function handleReactNativeWebImport( + declaration: ImportDeclaration, + t: BabelTypes, + filename: string, +): Statement[] | undefined { + const { specifiers, source } = declaration; + + const rnwSource = parseReactNativeWebSource(source.value, filename); + if (!rnwSource) { + return; + } + + if (specifiers.length === 0) { + return [ + t.importDeclaration([], t.stringLiteral("react-native-css/components")), + ]; + } + + const statements: Statement[] = []; + + for (const specifier of specifiers) { + if (t.isImportDefaultSpecifier(specifier)) { + const { source: newSource, name } = rnwSource; + + if (!name) { + statements.push( + t.importDeclaration( + [t.importDefaultSpecifier(specifier.local)], + t.stringLiteral(newSource), + ), + ); + } else { + statements.push( + t.importDeclaration( + [t.importSpecifier(specifier.local, specifier.local)], + t.stringLiteral(`react-native-css/components/${name}`), + ), + ); + } + } else if (t.isImportNamespaceSpecifier(specifier)) { + statements.push( + t.importDeclaration( + [specifier], + t.stringLiteral("react-native-css/components"), + ), + ); + } else { + const localName = t.isStringLiteral(specifier.imported) + ? specifier.imported.value + : specifier.imported.name; + + if (!allowedModules.has(localName)) { + statements.push(t.importDeclaration([specifier], source)); + } else { + statements.push( + t.importDeclaration( + [t.importSpecifier(specifier.local, specifier.imported)], + t.stringLiteral(`react-native-css/components/${localName}`), + ), + ); + } + } + } + + return statements; +} + +export function handleReactNativeWebIdentifierRequire( + path: NodePath, + t: BabelTypes, + id: string, + source: string, + filename: string, +) { + const parsedSource = parseReactNativeWebSource(source, filename); + + if (!parsedSource) { + return; + } + + const { source: newSource, name } = parsedSource; + + if (name) { + return [ + t.variableDeclaration(path.node.kind, [ + t.variableDeclarator( + t.objectPattern([ + name === id + ? t.objectProperty( + t.identifier(name), + t.identifier(id), + false, + true, + ) + : t.objectProperty(t.identifier(name), t.identifier(id)), + ]), + t.callExpression(t.identifier("require"), [ + t.stringLiteral(newSource), + ]), + ), + ]), + ]; + } else { + return [ + t.variableDeclaration(path.node.kind, [ + t.variableDeclarator( + t.identifier(id), + t.callExpression(t.identifier("require"), [ + t.stringLiteral(newSource), + ]), + ), + ]), + ]; + } +} + +export function handleReactNativeWebObjectPatternRequire( + path: NodePath, + t: BabelTypes, + id: ObjectPattern, + source: string, + filename: string, +) { + const parsedSource = parseReactNativeWebSource(source, filename); + + // Don't handle `const { Text } = require('react-native-web/dist/cjs/Text');` - we only handle package exports + if (!parsedSource || parsedSource.name) { + return; + } + + const statements: Statement[] = []; + + for (const identifier of id.properties) { + if (t.isRestElement(identifier)) { + // Bail out on `const { ...rest } = require('react-native-web');` + // We need to exit as we do not handle `const { Text, ...rest } = require('react-native-web');` + return; + } else if ( + !(t.isIdentifier(identifier.value) && t.isIdentifier(identifier.key)) + ) { + // Bail out on anything that isn't `const { : } = require('react-native-web');` + return; + } else { + const name = identifier.key.name; + + if (!allowedModules.has(name)) { + statements.push( + t.variableDeclaration(path.node.kind, [ + t.variableDeclarator( + t.objectPattern([identifier]), + t.callExpression(t.identifier("require"), [ + t.stringLiteral(source), + ]), + ), + ]), + ); + } else { + const newSourceWithName = `react-native-css/components/${name}`; + + statements.push( + t.variableDeclaration(path.node.kind, [ + t.variableDeclarator( + t.objectPattern([ + t.objectProperty( + t.identifier(name), + t.identifier(identifier.value.name), + false, + true, + ), + ]), + t.callExpression(t.identifier("require"), [ + t.stringLiteral(newSourceWithName), + ]), + ), + ]), + ); + } + } + } + + return statements; +} diff --git a/src/babel/react-native.ts b/src/babel/react-native.ts new file mode 100644 index 0000000..8313935 --- /dev/null +++ b/src/babel/react-native.ts @@ -0,0 +1,225 @@ +import { type NodePath } from "@babel/traverse"; + +import tBabelTypes, { + type ImportDeclaration, + type ObjectPattern, + type Statement, + type VariableDeclaration, +} from "@babel/types"; +import { allowedModules } from "./allowedModules"; +import { dirname, resolve } from "path"; + +type BabelTypes = typeof tBabelTypes; + +function parseReactNativeSource(source: string, filename: string) { + if (source.startsWith(".")) { + source = resolve(dirname(filename), source); + + const internalPath = source.split("react-native/Libraries/Components/")[1]; + if (!internalPath) { + return; + } + + // Strip the absolute system filepath + source = `react-native/Libraries/Components/${internalPath}`; + } + + if (source === "react-native") { + return { source: "react-native-css/components" }; + } else if (source.startsWith("react-native/")) { + const name = source.split("/").at(-1); + if (!name || !allowedModules.has(name)) { + return; + } + + return { + source: `react-native-css/components/${name}`, + name, + }; + } + + return; +} + +export function handleReactNativeImport( + declaration: ImportDeclaration, + t: BabelTypes, + filename: string, +): Statement[] | undefined { + const { specifiers, source } = declaration; + + const rnwSource = parseReactNativeSource(source.value, filename); + if (!rnwSource) { + return; + } + + if (specifiers.length === 0) { + return [ + t.importDeclaration([], t.stringLiteral("react-native-css/components")), + ]; + } + + const statements: Statement[] = []; + + for (const specifier of specifiers) { + if (t.isImportDefaultSpecifier(specifier)) { + const { source: newSource, name } = rnwSource; + + if (!name) { + statements.push( + t.importDeclaration( + [t.importDefaultSpecifier(specifier.local)], + t.stringLiteral(newSource), + ), + ); + } else { + statements.push( + t.importDeclaration( + [t.importSpecifier(specifier.local, specifier.local)], + t.stringLiteral(`react-native-css/components/${name}`), + ), + ); + } + } else if (t.isImportNamespaceSpecifier(specifier)) { + statements.push( + t.importDeclaration( + [specifier], + t.stringLiteral("react-native-css/components"), + ), + ); + } else { + const localName = t.isStringLiteral(specifier.imported) + ? specifier.imported.value + : specifier.imported.name; + + if (!allowedModules.has(localName)) { + statements.push(t.importDeclaration([specifier], source)); + } else { + statements.push( + t.importDeclaration( + [t.importSpecifier(specifier.local, specifier.imported)], + t.stringLiteral(`react-native-css/components/${localName}`), + ), + ); + } + } + } + + return statements; +} + +export function handleReactNativeIdentifierRequire( + path: NodePath, + t: BabelTypes, + id: string, + source: string, + filename: string, +) { + const parsedSource = parseReactNativeSource(source, filename); + + if (!parsedSource) { + return; + } + + const { source: newSource, name } = parsedSource; + + if (name) { + return [ + t.variableDeclaration(path.node.kind, [ + t.variableDeclarator( + t.objectPattern([ + name === id + ? t.objectProperty( + t.identifier(name), + t.identifier(id), + false, + true, + ) + : t.objectProperty(t.identifier(name), t.identifier(id)), + ]), + t.callExpression(t.identifier("require"), [ + t.stringLiteral(newSource), + ]), + ), + ]), + ]; + } else { + return [ + t.variableDeclaration(path.node.kind, [ + t.variableDeclarator( + t.identifier(id), + t.callExpression(t.identifier("require"), [ + t.stringLiteral(newSource), + ]), + ), + ]), + ]; + } +} + +export function handleReactNativeObjectPatternRequire( + path: NodePath, + t: BabelTypes, + id: ObjectPattern, + source: string, + filename: string, +) { + const parsedSource = parseReactNativeSource(source, filename); + + // Don't handle `const { Text } = require('react-native/Libraries/Text/Text');` - we only handle package exports + if (!parsedSource || parsedSource.name) { + return; + } + + const statements: Statement[] = []; + + for (const identifier of id.properties) { + if (t.isRestElement(identifier)) { + // Bail out on `const { ...rest } = require('react-native');` + // We need to exit as we do not handle `const { Text, ...rest } = require('react-native');` + return; + } else if ( + !(t.isIdentifier(identifier.value) && t.isIdentifier(identifier.key)) + ) { + // Bail out on anything that isn't `const { : } = require('react-native');` + return; + } else { + const name = identifier.key.name; + + if (!allowedModules.has(name)) { + statements.push( + t.variableDeclaration(path.node.kind, [ + t.variableDeclarator( + t.objectPattern([identifier]), + t.callExpression(t.identifier("require"), [ + t.stringLiteral(source), + ]), + ), + ]), + ); + } else { + const newSourceWithName = `react-native-css/components/${name}`; + + statements.push( + t.variableDeclaration(path.node.kind, [ + t.variableDeclarator( + t.objectPattern([ + t.objectProperty( + t.identifier(name), + t.identifier(identifier.value.name), + false, + true, + ), + ]), + t.callExpression(t.identifier("require"), [ + t.stringLiteral(newSourceWithName), + ]), + ), + ]), + ); + } + } + } + + return statements; +} diff --git a/src/components/ActivityIndicator.tsx b/src/components/ActivityIndicator.tsx deleted file mode 100644 index ca39bd4..0000000 --- a/src/components/ActivityIndicator.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { - ActivityIndicator as RNActivityIndicator, - type ActivityIndicatorProps, -} from "react-native"; - -import { useCssElement, type StyledConfiguration } from "../runtime"; - -const mapping: StyledConfiguration = { - className: { - target: "style", - nativeStyleMapping: { - color: "color", - }, - }, -}; - -export function ActivityIndicator(props: ActivityIndicatorProps) { - return useCssElement(RNActivityIndicator, props, mapping); -} diff --git a/src/components/SafeAreaProvider/index.tsx b/src/components/SafeAreaProvider/index.tsx deleted file mode 100644 index 5b398f8..0000000 --- a/src/components/SafeAreaProvider/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* eslint-disable */ -import { - useContext, - useMemo, - type ComponentProps, - type PropsWithChildren, -} from "react"; - -import { - SafeAreaProvider as RNSafeAreaProvider, - useSafeAreaInsets, -} from "react-native-safe-area-context"; - -import { - VariableContext, - type VariableContextValue, -} from "../../runtime/native/reactivity"; - -export function SafeAreaProvider({ - children, - ...props -}: ComponentProps) { - return ( - - {children} - - ); -} - -function SafeAreaProviderEnv({ children }: PropsWithChildren) { - const insets = useSafeAreaInsets(); - const parentVarContext = useContext(VariableContext); - - const value = useMemo( - () => ({ - ...parentVarContext, - "--react-native-css-safe-area-inset-bottom": insets.bottom, - "--react-native-css-safe-area-inset-left": insets.left, - "--react-native-css-safe-area-inset-right": insets.right, - "--react-native-css-safe-area-inset-top": insets.top, - }), - [parentVarContext, insets], - ); - - return ( - - {children} - - ); -} diff --git a/src/components/SafeAreaProvider/index.web.tsx b/src/components/SafeAreaProvider/index.web.tsx deleted file mode 100644 index f7419df..0000000 --- a/src/components/SafeAreaProvider/index.web.tsx +++ /dev/null @@ -1 +0,0 @@ -export { SafeAreaProvider } from "react-native-safe-area-context"; diff --git a/src/components/Text.tsx b/src/components/Text.tsx index 3496b31..1a672d8 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -13,3 +13,5 @@ const mapping = { export function Text(props: StyledProps) { return useCssElement(RNText, props, mapping); } + +export default Text; diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx deleted file mode 100644 index 54afe9f..0000000 --- a/src/components/TextInput.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { TextInput as RNTextInput, type TextInputProps } from "react-native"; - -import { - useCssElement, - type StyledConfiguration, - type StyledProps, -} from "../runtime"; - -const mapping = { - className: { - target: "style", - nativeStyleMapping: { - selectionColor: "selectionColor", - }, - }, -} satisfies StyledConfiguration; - -export function TextInput(props: StyledProps) { - return useCssElement(RNTextInput, props, mapping); -} diff --git a/src/components/View.tsx b/src/components/View.tsx index bacfc50..e11718a 100644 --- a/src/components/View.tsx +++ b/src/components/View.tsx @@ -13,3 +13,5 @@ const mapping = { export function View(props: StyledProps) { return useCssElement(RNView, props, mapping); } + +export default View; diff --git a/src/index.ts b/src/index.ts index e3ce6fd..e559619 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,3 @@ export * from "./runtime"; + +export * from "./poison.pill"; diff --git a/src/metro/index.ts b/src/metro/index.ts index ddd0989..63b7de1 100644 --- a/src/metro/index.ts +++ b/src/metro/index.ts @@ -8,13 +8,15 @@ import type { MetroConfig } from "metro-config"; import { compile } from "../compiler/compiler"; import { setupTypeScript } from "./typescript"; import { getInjectionCode } from "./injection-code"; +import { nativeResolver, webResolver } from "./resolver"; export interface WithReactNativeCSSOptions { - browserslist?: string | null; - browserslistEnv?: string | null; - libName?: string; + /* Specify the path to the TypeScript environment file. Defaults types-env.d.ts */ typescriptEnvPath?: string; + /* Disable generation of the types-env.d.ts file. Defaults false */ disableTypeScriptGeneration?: boolean; + /** Add className to all React Native primitives. Defaults false */ + globalClassNamePolyfill?: boolean; } export function withReactNativeCSS< @@ -26,23 +28,53 @@ export function withReactNativeCSS< }) as T; } - if (Number(versions.node.split(".")[0]) < 18) { - throw new Error("react-native-css only supports NodeJS >18"); + if (Number(versions.node.split(".")[0]) < 20) { + throw new Error("react-native-css only supports NodeJS >20"); } - if (options?.disableTypeScriptGeneration !== true) { - setupTypeScript(options?.typescriptEnvPath, options?.libName); + const { + disableTypeScriptGeneration, + typescriptEnvPath, + globalClassNamePolyfill = false, + } = options || {}; + + if (disableTypeScriptGeneration !== true) { + setupTypeScript(typescriptEnvPath); } const originalMiddleware = config.server?.enhanceMiddleware; + const originalResolver = config.resolver?.resolveRequest; + + const poisonPillPath = "./poison.pill"; return { ...config, transformerPath: require.resolve("./metro-transformer"), - transformer: { - ...config.transformer, - browserslist: options?.browserslist, - browserslistEnv: options?.browserslistEnv, + resolver: { + ...config.resolver, + sourceExts: [...(config?.resolver?.sourceExts || []), "css"], + resolveRequest: (context, moduleName, platform) => { + if (moduleName === poisonPillPath) { + return { type: "empty" }; + } + + // Don't hijack the resolution of react-native imports + if (!globalClassNamePolyfill) { + const parentResolver = originalResolver ?? context.resolveRequest; + return parentResolver(context, moduleName, platform); + } + + const parentResolver = originalResolver ?? context.resolveRequest; + const resolver = platform === "web" ? webResolver : nativeResolver; + const resolved = resolver( + parentResolver, + context, + moduleName, + platform, + ); + + return resolved; + }, }, server: { ...config.server, diff --git a/src/metro/resolver.ts b/src/metro/resolver.ts new file mode 100644 index 0000000..fc17f8a --- /dev/null +++ b/src/metro/resolver.ts @@ -0,0 +1,88 @@ +import type { + Resolution, + CustomResolutionContext, + CustomResolver, +} from "metro-resolver"; +import { basename, resolve, sep } from "node:path"; +import { allowedModules } from "../babel/allowedModules"; + +const thisModuleDist = resolve(__dirname, "../../../dist"); +const thisModuleSrc = resolve(__dirname, "../../../src"); + +function isFromThisModule(filename: string): boolean { + return ( + filename.startsWith(thisModuleDist) || filename.startsWith(thisModuleSrc) + ); +} + +export function nativeResolver( + resolver: CustomResolver, + context: CustomResolutionContext, + moduleName: string, + platform: string | null, +): Resolution { + const resolution = resolver(context, moduleName, platform); + + if ( + // Don't include our internal files + isFromThisModule(context.originModulePath) || + // Only operate on source files + resolution.type !== "sourceFile" || + // Skip the React Native barrel file to prevent infinite recursion + context.originModulePath.endsWith("react-native/index.js") || + // Skip anything that isn't importing a React Native component + !( + moduleName.startsWith("react-native") || + moduleName.startsWith("react-native/") || + resolution.filePath.includes("react-native/Libraries/Components/View/") + ) + ) { + return resolution; + } + + if (moduleName === "react-native") { + return resolver(context, `react-native-css/components`, platform); + } + + // We only care about `react-native/Library/Components/.js` components + const segments = resolution.filePath.split(sep); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const module = basename(segments.at(-1)!).split(".")[0]; + + if (!module || !allowedModules.has(module)) { + return resolution; + } + + return resolver(context, `react-native-css/components/${module}`, platform); +} + +export function webResolver( + resolver: CustomResolver, + context: CustomResolutionContext, + moduleName: string, + platform: string | null, +): Resolution { + const resolution = resolver(context, moduleName, platform); + + if ( + // Don't include our internal files + isFromThisModule(context.originModulePath) || + // Only operate on source files + resolution.type !== "sourceFile" || + // Skip anything that isn't importing from `react-native-web` + !resolution.filePath.includes(`${sep}react-native-web${sep}`) + ) { + return resolution; + } + + // We only care about `react-native-web////index.js` components + const segments = resolution.filePath.split(sep); + const isIndex = segments.at(-1)?.startsWith("index."); + const module = segments.at(-2); + + if (!isIndex || !module || !allowedModules.has(module)) { + return resolution; + } + + return resolver(context, `react-native-css/components/${module}`, platform); +} diff --git a/src/poison.pill.ts b/src/poison.pill.ts new file mode 100644 index 0000000..c5cf459 --- /dev/null +++ b/src/poison.pill.ts @@ -0,0 +1,32 @@ +throw new Error(`react-native-css has encountered a setup error. + +┌─────-─┐ +| Metro | +└─────-─┘ + +Either your metro.config.js is missing the 'withReactNativeCSS' wrapper OR +the resolver.resolveRequest function of your config is being overridden, and not calling the parent resolver. + +When using 3rd party libraries, please use withReactNativeCSS as the innermost function when composing Metro configs. + +\`\`\`ts +const config = getDefaultConfig(__dirname); +module.exports = with3rdPartyPlugin( + withReactNativeCSS(config) +) +\`\`\` + +┌─────------─┐ +| NativeWind | +└─────------─┘ + +If you are using NativeWind with the 'withNativeWind' function, follow the Metro instructions above, but use 'withNativeWind' instead of 'withReactNativeCSS'. + +┌─────----------─┐ +| Other bundlers | +└─────----------─┘ + +If you are using another bundler (Vite, Webpack, etc), or non-Metro framework (Next.js, Remix, etc), please ensure you have included 'react-native-css/babel' as a babel preset. +`); + +export {}; diff --git a/src/runtime/native/__tests__/env.test.ios.tsx b/src/runtime/native/__tests__/env.test.ios.tsx index 6d0a7a3..8c090b8 100644 --- a/src/runtime/native/__tests__/env.test.ios.tsx +++ b/src/runtime/native/__tests__/env.test.ios.tsx @@ -1,4 +1,4 @@ -import { SafeAreaProvider } from "react-native-css/components/SafeAreaProvider"; +// import { SafeAreaProvider } from "react-native-css/components/SafeAreaProvider"; import { View } from "react-native-css/components/View"; import { registerCSS, render, screen, testID } from "react-native-css/jest"; @@ -10,6 +10,9 @@ test("safe-area-inset-*", () => { margin-right: env(safe-area-inset-right); }`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const SafeAreaProvider = View as any; + render(