mirror of
https://github.com/zhigang1992/nativewind.git
synced 2026-01-12 22:49:13 +08:00
feat: platform specific theming
This commit is contained in:
10
__tests__/visitor/custom-theme/code.tsx
Normal file
10
__tests__/visitor/custom-theme/code.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Text } from "react-native";
|
||||
import { TailwindProvider } from "tailwindcss-react-native";
|
||||
|
||||
export function Test() {
|
||||
return (
|
||||
<TailwindProvider>
|
||||
<Text className="text-blue-500">Hello world!</Text>
|
||||
</TailwindProvider>
|
||||
);
|
||||
}
|
||||
3
__tests__/visitor/custom-theme/options.json
Normal file
3
__tests__/visitor/custom-theme/options.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"tailwindConfigPath": "./custom-theme/tailwind.config.js"
|
||||
}
|
||||
33
__tests__/visitor/custom-theme/output.tsx
Normal file
33
__tests__/visitor/custom-theme/output.tsx
Normal file
@@ -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 (
|
||||
<TailwindProvider>
|
||||
<StyledComponent className="text-blue-500" component={Text}>
|
||||
Hello world!
|
||||
</StyledComponent>
|
||||
</TailwindProvider>
|
||||
);
|
||||
}
|
||||
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 || {},
|
||||
{}
|
||||
);
|
||||
16
__tests__/visitor/custom-theme/tailwind.config.js
Normal file
16
__tests__/visitor/custom-theme/tailwind.config.js
Normal file
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,3 +20,20 @@ export function prependImport(
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function prependImports(
|
||||
body: Statement[],
|
||||
variables: Array<string | string[]>,
|
||||
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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ function babelSerializeObject(literal: any): Expression {
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("unserializable literal");
|
||||
throw new Error("un-serializable literal");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface VisitorState
|
||||
tailwindConfigPath: string;
|
||||
canCompile: boolean;
|
||||
canTransform: boolean;
|
||||
didTransform: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,6 +64,7 @@ export const visitor: Visitor<VisitorState> = {
|
||||
}
|
||||
|
||||
if (someAttributes(path, ["className", "tw"]) && canTransform) {
|
||||
state.didTransform ||= true;
|
||||
toStyledComponent(path);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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(")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String["replace"]> = [
|
||||
// '"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<String["replace"]> = [
|
||||
// '"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];
|
||||
}
|
||||
|
||||
67
src/utils/with-platform-theme.ts
Normal file
67
src/utils/with-platform-theme.ts
Normal file
@@ -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<string, unknown> = {};
|
||||
|
||||
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<string, unknown>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(config.theme) as Array<
|
||||
[keyof TailwindTheme, string | Record<string, unknown>]
|
||||
>) {
|
||||
resolvePlatformThemes(key, value);
|
||||
}
|
||||
|
||||
(config.theme as Record<string, unknown>).extend = extendTheme;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function hasPlatformKeys(themeObject: object) {
|
||||
return (
|
||||
"ios" in themeObject ||
|
||||
"android" in themeObject ||
|
||||
"web" in themeObject ||
|
||||
"windows" in themeObject ||
|
||||
"macos" in themeObject
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user