feat: platform specific theming

This commit is contained in:
Mark Lawlor
2022-06-05 19:28:01 +10:00
parent 1c9d29181c
commit 0b483eafd3
12 changed files with 344 additions and 120 deletions

View 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>
);
}

View File

@@ -0,0 +1,3 @@
{
"tailwindConfigPath": "./custom-theme/tailwind.config.js"
}

View 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 || {},
{}
);

View 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",
},
},
},
},
},
};

View File

@@ -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");
}
},
},

View File

@@ -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)
)
);
}

View File

@@ -119,7 +119,7 @@ function babelSerializeObject(literal: any): Expression {
);
}
throw new Error("unserializable literal");
throw new Error("un-serializable literal");
}
}

View File

@@ -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);
}

View File

@@ -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);
}
},

View File

@@ -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(")
);
}

View File

@@ -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];
}

View 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
);
}