diff --git a/__tests__/visitor/custom-theme/code.tsx b/__tests__/visitor/custom-theme/code.tsx
new file mode 100644
index 0000000..ac4fc5f
--- /dev/null
+++ b/__tests__/visitor/custom-theme/code.tsx
@@ -0,0 +1,10 @@
+import { Text } from "react-native";
+import { TailwindProvider } from "tailwindcss-react-native";
+
+export function Test() {
+ return (
+
+ Hello world!
+
+ );
+}
diff --git a/__tests__/visitor/custom-theme/options.json b/__tests__/visitor/custom-theme/options.json
new file mode 100644
index 0000000..75183ec
--- /dev/null
+++ b/__tests__/visitor/custom-theme/options.json
@@ -0,0 +1,3 @@
+{
+ "tailwindConfigPath": "./custom-theme/tailwind.config.js"
+}
diff --git a/__tests__/visitor/custom-theme/output.tsx b/__tests__/visitor/custom-theme/output.tsx
new file mode 100644
index 0000000..2b4e666
--- /dev/null
+++ b/__tests__/visitor/custom-theme/output.tsx
@@ -0,0 +1,33 @@
+import {
+ StyleSheet as RNStyleSheet,
+ Platform as RNPlatform,
+ PlatformColor as RNPlatformColor,
+} from "react-native";
+import { StyledComponent } from "tailwindcss-react-native";
+import { Text } from "react-native";
+import { TailwindProvider } from "tailwindcss-react-native";
+export function Test() {
+ return (
+
+
+ Hello world!
+
+
+ );
+}
+globalThis.tailwindcss_react_native_style = Object.assign(
+ globalThis.tailwindcss_react_native_style || {},
+ RNStyleSheet.create({
+ "text-blue-500": {
+ color: RNPlatform.select({
+ ios: RNPlatformColor("systemTealColor"),
+ android: RNPlatformColor("@androidcolor/holo_blue_bright"),
+ default: "black",
+ }),
+ },
+ })
+);
+globalThis.tailwindcss_react_native_media = Object.assign(
+ globalThis.tailwindcss_react_native_media || {},
+ {}
+);
diff --git a/__tests__/visitor/custom-theme/tailwind.config.js b/__tests__/visitor/custom-theme/tailwind.config.js
new file mode 100644
index 0000000..d22eaa8
--- /dev/null
+++ b/__tests__/visitor/custom-theme/tailwind.config.js
@@ -0,0 +1,16 @@
+module.exports = {
+ content: [`./custom-theme/*.{js,ts,jsx,tsx}`],
+ theme: {
+ extend: {
+ colors: {
+ blue: {
+ 500: {
+ ios: "platformColor(systemTealColor)",
+ android: "platformColor(@android:color/holo_blue_bright)",
+ default: "black",
+ },
+ },
+ },
+ },
+ },
+};
diff --git a/src/babel/root-visitor.ts b/src/babel/root-visitor.ts
index fa15351..3c97a1c 100644
--- a/src/babel/root-visitor.ts
+++ b/src/babel/root-visitor.ts
@@ -4,7 +4,7 @@ import { NodePath } from "@babel/traverse";
import { extractStyles } from "../postcss/extract-styles";
import { appendVariables } from "./transforms/append-variables";
-import { prependImport } from "./transforms/append-import";
+import { prependImport, prependImports } from "./transforms/append-import";
import { TailwindcssReactNativeBabelOptions, State } from "./types";
import { visitor, VisitorState } from "./visitor";
import { getAllowedOptions, isAllowedProgramPath } from "./utils/allowed-paths";
@@ -69,6 +69,7 @@ export default function rootVisitor(
platform: "native",
hmr: true,
mode: "compileAndTransform",
+ didTransform: false,
blockModuleTransform: [],
hasStyledComponentImport: false,
canCompile,
@@ -90,72 +91,66 @@ export default function rootVisitor(
const {
filename,
hasStyleSheetImport,
+ didTransform,
hasProvider,
hasStyledComponentImport,
hmr,
} = visitorState;
- if (hmr) {
- /**
- * Override tailwind to only process the classnames in this file
- */
- const { output } = extractStyles({
- ...tailwindConfig,
- content: [filename],
- // If the file doesn't have any Tailwind styles, it will print a warning
- // We force an empty style to prevent this
- safelist: ["babel-empty"],
- serializer: babelStyleSerializer,
- });
+ /*
+ * If we are not hmr, we only care if this file has a provider
+ */
+ if (!hmr && !hasProvider) {
+ return;
+ }
- const bodyNode = path.node.body;
+ const extractStylesOptions = hmr
+ ? {
+ ...tailwindConfig,
+ content: [filename],
+ // If the file doesn't have any Tailwind styles, it will print a warning
+ // We force an empty style to prevent this
+ safelist: ["babel-empty"],
+ serializer: babelStyleSerializer,
+ }
+ : {
+ ...tailwindConfig,
+ serializer: babelStyleSerializer,
+ };
- if (!hasStyledComponentImport && canTransform) {
- prependImport(
- bodyNode,
- "StyledComponent",
- "tailwindcss-react-native"
- );
- }
+ const { output } = extractStyles(extractStylesOptions);
- // If there are no styles, early exit
- if (Object.keys(output.styles).length === 0) return;
+ const bodyNode = path.node.body;
- appendVariables(bodyNode, output);
+ if (didTransform && !hasStyledComponentImport) {
+ prependImport(
+ bodyNode,
+ "StyledComponent",
+ "tailwindcss-react-native"
+ );
+ }
- if (!hasStyleSheetImport) {
- prependImport(
- bodyNode,
- ["RNStyleSheet", "StyleSheet"],
- "react-native"
- );
- }
- } else {
- if (!hasProvider) {
- return;
- }
+ // If there are no styles, early exit
+ if (Object.keys(output.styles).length === 0) return;
- /**
- * Override tailwind to only process the classnames in this file
- */
- const { output } = extractStyles({
- ...tailwindConfig,
- serializer: babelStyleSerializer,
- });
+ appendVariables(bodyNode, output);
- // If there are no styles, early exit
- if (Object.keys(output.styles).length === 0) return;
+ const imports = [];
- const bodyNode = path.node.body;
- appendVariables(bodyNode, output);
+ if (!hasStyleSheetImport) {
+ imports.push(["RNStyleSheet", "StyleSheet"]);
+ }
- if (!hasStyleSheetImport) {
- prependImport(
- bodyNode,
- ["RNStyleSheet", "StyleSheet"],
- "react-native"
- );
- }
+ if (output.hasPlatform) {
+ imports.push(["RNPlatform", "Platform"]);
+ }
+
+ if (output.hasPlatformColor) {
+ imports.push(["RNPlatformColor", "PlatformColor"]);
+ }
+
+ if (imports.length > 0) {
+ prependImports(bodyNode, imports, "react-native");
}
},
},
diff --git a/src/babel/transforms/append-import.ts b/src/babel/transforms/append-import.ts
index fc4b914..0460e36 100644
--- a/src/babel/transforms/append-import.ts
+++ b/src/babel/transforms/append-import.ts
@@ -20,3 +20,20 @@ export function prependImport(
)
);
}
+
+export function prependImports(
+ body: Statement[],
+ variables: Array,
+ source: string
+) {
+ body.unshift(
+ importDeclaration(
+ variables.map((variable) => {
+ return typeof variable === "string"
+ ? importSpecifier(identifier(variable), identifier(variable))
+ : importSpecifier(identifier(variable[0]), identifier(variable[1]));
+ }),
+ stringLiteral(source)
+ )
+ );
+}
diff --git a/src/babel/transforms/append-variables.ts b/src/babel/transforms/append-variables.ts
index 7b9d48c..e5ea5d9 100644
--- a/src/babel/transforms/append-variables.ts
+++ b/src/babel/transforms/append-variables.ts
@@ -119,7 +119,7 @@ function babelSerializeObject(literal: any): Expression {
);
}
- throw new Error("unserializable literal");
+ throw new Error("un-serializable literal");
}
}
diff --git a/src/babel/utils/get-tailwind-config.ts b/src/babel/utils/get-tailwind-config.ts
index b254632..df05c3f 100644
--- a/src/babel/utils/get-tailwind-config.ts
+++ b/src/babel/utils/get-tailwind-config.ts
@@ -1,9 +1,9 @@
import { existsSync } from "node:fs";
-import resolveTailwindConfig from "tailwindcss/resolveConfig";
import { TailwindConfig } from "tailwindcss/tailwind-config";
import { nativePlugin, NativePluginOptions } from "../../tailwind/native";
+import { withPlatformTheme } from "../../utils/with-platform-theme";
export interface GetTailwindConfigOptions extends NativePluginOptions {
tailwindConfigPath?: string;
@@ -27,7 +27,7 @@ export function getTailwindConfig(
const mergedConfig = {
...userConfig,
plugins: [nativePlugin(options), ...(userConfig.plugins ?? [])],
- };
+ } as TailwindConfig;
- return resolveTailwindConfig(mergedConfig as TailwindConfig);
+ return withPlatformTheme(mergedConfig);
}
diff --git a/src/babel/visitor.ts b/src/babel/visitor.ts
index e84cec9..f573445 100644
--- a/src/babel/visitor.ts
+++ b/src/babel/visitor.ts
@@ -27,6 +27,7 @@ export interface VisitorState
tailwindConfigPath: string;
canCompile: boolean;
canTransform: boolean;
+ didTransform: boolean;
}
/**
@@ -63,6 +64,7 @@ export const visitor: Visitor = {
}
if (someAttributes(path, ["className", "tw"]) && canTransform) {
+ state.didTransform ||= true;
toStyledComponent(path);
}
},
diff --git a/src/postcss/to-react-native/properties/only.ts b/src/postcss/to-react-native/properties/only.ts
index 0230ab7..be07aa4 100644
--- a/src/postcss/to-react-native/properties/only.ts
+++ b/src/postcss/to-react-native/properties/only.ts
@@ -114,5 +114,9 @@ export function only<
}
function isFunctionValue(value: string) {
- return value.startsWith("styleSheet(") || value.startsWith("platformColor(");
+ return (
+ value.startsWith("styleSheet(") ||
+ value.startsWith("platformColor(") ||
+ value.startsWith("platform(")
+ );
}
diff --git a/src/utils/serialize-styles.ts b/src/utils/serialize-styles.ts
index f839df9..6bbb848 100644
--- a/src/utils/serialize-styles.ts
+++ b/src/utils/serialize-styles.ts
@@ -1,7 +1,10 @@
import {
callExpression,
identifier,
+ isExpression,
memberExpression,
+ objectExpression,
+ objectProperty,
stringLiteral,
} from "@babel/types";
import { MediaRecord, Style, StyleRecord } from "../types/common";
@@ -11,6 +14,8 @@ export interface DefaultSerializedStyles {
[k: string]: Style;
};
media: MediaRecord;
+ hasPlatform: boolean;
+ hasPlatformColor: boolean;
}
/**
@@ -22,6 +27,9 @@ export function serializeStyles(
): DefaultSerializedStyles {
const media: MediaRecord = {};
+ let hasPlatform = false;
+ let hasPlatformColor = false;
+
const styles = Object.fromEntries(
Object.entries(styleRecord).flatMap(([key, value]) => {
return value.map((style) => {
@@ -36,7 +44,13 @@ export function serializeStyles(
if (replacer) {
style = Object.fromEntries(
- Object.entries(style).map((v) => replacer(...v))
+ Object.entries(style).map(([k, v]) => {
+ if (typeof v === "string") {
+ hasPlatform ||= v.includes("platform(");
+ hasPlatformColor ||= v.includes("platformColor(");
+ }
+ return replacer(k, v);
+ })
);
}
@@ -45,7 +59,7 @@ export function serializeStyles(
})
);
- return { styles, media };
+ return { styles, media, hasPlatform, hasPlatformColor };
}
/**
@@ -69,36 +83,75 @@ export function testStyleSerializer(styleRecord: StyleRecord) {
* Replaces platform function strings with actual platform functions
*/
export function babelStyleSerializer(styleRecord: StyleRecord) {
- return serializeStyles(styleRecord, (key, value) => {
- if (typeof value !== "string") {
- return [key, value];
- }
-
- if (value === "styleSheet(hairlineWidth)") {
- return [
- key,
- memberExpression(
- identifier("RNStyleSheet"),
- identifier("hairlineWidth")
- ),
- ];
- }
-
- if (value.startsWith("platformColor(")) {
- const result = /platformColor\((.+?)\)/.exec(value);
-
- if (!result) return [key, identifier("undefined")];
-
- const variables = result[1]
- .split(/[ ,]+/)
- .filter(Boolean)
- .map((v: string) => stringLiteral(v));
-
- return [key, callExpression(identifier("RNPlatformColor"), variables)];
- }
+ return serializeStyles(styleRecord, babelReplacer);
+}
+function babelReplacer(key: string, value: string): [string, unknown] {
+ if (typeof value !== "string") {
return [key, value];
- });
+ }
+
+ if (value === "styleSheet(hairlineWidth)") {
+ return [
+ key,
+ memberExpression(identifier("RNStyleSheet"), identifier("hairlineWidth")),
+ ];
+ }
+
+ if (value.startsWith("platformColor(")) {
+ const result = value.match(/platformColor\((.+)\)/);
+
+ if (!result) return [key, identifier("undefined")];
+
+ const variables = result[1]
+ .split(/[ ,]+/)
+ .filter(Boolean)
+ .map((v: string) => stringLiteral(v));
+
+ return [key, callExpression(identifier("RNPlatformColor"), variables)];
+ }
+
+ if (value.startsWith("platform(")) {
+ const result = value.match(/platform\((.+)\)/);
+
+ if (!result) return [key, identifier("undefined")];
+
+ const props = result[1]
+ .trim()
+ .split(/\s+/)
+ .map((a) => {
+ // Edge case:
+ // platform(android:platformColor(@android:color/holo_blue_bright))
+ // Sometimes the value can has a colon, so we need to collect all values
+ // and join them
+ let [platform, ...values] = a.split(":");
+
+ if (!values) {
+ values = [platform];
+ platform = "default";
+ }
+
+ const [key, value] = babelReplacer(platform, `${values.join("")}`);
+
+ if (typeof value === "object" && isExpression(value)) {
+ return objectProperty(identifier(key), value);
+ } else if (typeof value === "string") {
+ return objectProperty(identifier(key), stringLiteral(value));
+ } else {
+ throw new TypeError("Shouldn't reach here");
+ }
+ });
+
+ return [
+ key,
+ callExpression(
+ memberExpression(identifier("RNPlatform"), identifier("select")),
+ [objectExpression(props)]
+ ),
+ ];
+ }
+
+ return [key, value];
}
/**
@@ -107,43 +160,67 @@ export function babelStyleSerializer(styleRecord: StyleRecord) {
* Replaces platform function strings with actual platform functions
*/
export function postCSSSerializer(styleRecord: StyleRecord) {
- const serialized = serializeStyles(styleRecord);
-
- const styleString = JSON.stringify(serialized.styles)
- .replace(...styleSheetReplacer)
- .replace(...platformColorReplacer);
-
- const mediaString = JSON.stringify(serialized.media);
+ const { styles, media, ...rest } = serializeStyles(
+ styleRecord,
+ postCSSReplacer
+ );
return {
- style: styleString,
- media: mediaString,
+ style: JSON.stringify(styles).replaceAll(/"?!!!"?/g, ""),
+ media: JSON.stringify(media),
+ ...rest,
};
}
-// eslint-disable-next-line @typescript-eslint/ban-types
-const styleSheetReplacer: Parameters = [
- // '"styleSheet(hairlineWidth)"' will be captured as ["styleSheet", "hairlineWidth"]
- new RegExp(`"(styleSheet)\\((.+?)\\)"`, "g"),
- // [styleSheet, hairlineWidth] => StyleSheet.hairlineWidth
- (_: string, s: string, attribute: string) => {
- const styleSheet = s.charAt(0).toUpperCase() + s.slice(1);
- return `${styleSheet}.${attribute}`;
- },
-];
+function postCSSReplacer(key: string, value: string): [string, unknown] {
+ if (typeof value !== "string") {
+ return [key, value];
+ }
-// eslint-disable-next-line @typescript-eslint/ban-types
-const platformColorReplacer: Parameters = [
- // '"platformColor(color1, color2)"' will be captured as ["platformColor", "color1, color2"]
- new RegExp(`"(platformColor)\\((.+?)\\)"`, "g"),
- // [platformColor, "color1, color2"] => PlatformColor("color1","color2")
- (_: string, s: string, values: string) => {
- const platformColor = s.charAt(0).toUpperCase() + s.slice(1);
- const variables = values
+ if (value === "styleSheet(hairlineWidth)") {
+ return [key, "!!!RNStyleSheet.hairlineWidth!!!"];
+ }
+
+ if (value.startsWith("platformColor(")) {
+ const result = value.match(/platformColor\((.+)\)/);
+
+ if (!result) return [key, "!!!undefined!!!"];
+
+ const variables = result[1]
.split(/[ ,]+/)
.filter(Boolean)
- .map((v) => `"${v}"`)
- .join(",");
- return `${platformColor}(${variables})`;
- },
-];
+ .map((v: string) => `'${v}'`);
+
+ return [key, `!!!RNPlatformColor(${variables.join(",")})!!!`];
+ }
+
+ if (value.startsWith("platform(")) {
+ const result = value.match(/platform\((.+)\)/);
+
+ if (!result) return [key, "!!!undefined!!!"];
+
+ const props = result[1]
+ .trim()
+ .split(/\s+/)
+ .map((a) => {
+ // Edge case:
+ // platform(android:platformColor(@android:color/holo_blue_bright))
+ // Sometimes the value can has a colon, so we need to collect all values
+ // and join them
+ let [platform, ...values] = a.split(":");
+
+ if (!values) {
+ values = [platform];
+ platform = "default";
+ }
+
+ const [key, value] = postCSSReplacer(platform, `${values.join("")}`);
+
+ return `"${key}": ${value}`;
+ });
+
+ return [key, `!!!RNPlatform.select({ ${props} })!!!`];
+ }
+
+ return [key, value];
+}
diff --git a/src/utils/with-platform-theme.ts b/src/utils/with-platform-theme.ts
new file mode 100644
index 0000000..0720085
--- /dev/null
+++ b/src/utils/with-platform-theme.ts
@@ -0,0 +1,67 @@
+import { TailwindConfig, TailwindTheme } from "tailwindcss/tailwind-config";
+import resolveTailwindConfig from "tailwindcss/resolveConfig";
+
+interface WithPlatformThemeOptions {
+ previewCss?: boolean;
+}
+
+export function withPlatformTheme(
+ tailwindConfig: TailwindConfig,
+ { previewCss = false }: WithPlatformThemeOptions
+) {
+ const config = resolveTailwindConfig(tailwindConfig);
+
+ if (!config.theme) return config;
+
+ const extendTheme: Record = {};
+
+ function resolvePlatformThemes(
+ key: string,
+ value: unknown,
+ root = extendTheme
+ ) {
+ if (typeof value !== "object") return;
+ if (value === null) return;
+
+ if (hasPlatformKeys(value)) {
+ if (previewCss) {
+ root[key] = value.web || value.default;
+ } else {
+ const platformParameter = Object.entries(value)
+ .map((entry) => entry.join(":"))
+ .join(" ");
+
+ root[key] = `platform(${platformParameter})`;
+ }
+ } else {
+ root[key] ??= {};
+ for (const [valueKey, valueValue] of Object.entries(value)) {
+ resolvePlatformThemes(
+ valueKey,
+ valueValue,
+ root[key] as Record
+ );
+ }
+ }
+ }
+
+ for (const [key, value] of Object.entries(config.theme) as Array<
+ [keyof TailwindTheme, string | Record]
+ >) {
+ resolvePlatformThemes(key, value);
+ }
+
+ (config.theme as Record).extend = extendTheme;
+
+ return config;
+}
+
+function hasPlatformKeys(themeObject: object) {
+ return (
+ "ios" in themeObject ||
+ "android" in themeObject ||
+ "web" in themeObject ||
+ "windows" in themeObject ||
+ "macos" in themeObject
+ );
+}