From 0b483eafd30a13ff3dcefb80a1bd200dbeab9645 Mon Sep 17 00:00:00 2001 From: Mark Lawlor Date: Sun, 5 Jun 2022 19:28:01 +1000 Subject: [PATCH] feat: platform specific theming --- __tests__/visitor/custom-theme/code.tsx | 10 + __tests__/visitor/custom-theme/options.json | 3 + __tests__/visitor/custom-theme/output.tsx | 33 +++ .../visitor/custom-theme/tailwind.config.js | 16 ++ src/babel/root-visitor.ts | 101 +++++---- src/babel/transforms/append-import.ts | 17 ++ src/babel/transforms/append-variables.ts | 2 +- src/babel/utils/get-tailwind-config.ts | 6 +- src/babel/visitor.ts | 2 + .../to-react-native/properties/only.ts | 6 +- src/utils/serialize-styles.ts | 201 ++++++++++++------ src/utils/with-platform-theme.ts | 67 ++++++ 12 files changed, 344 insertions(+), 120 deletions(-) create mode 100644 __tests__/visitor/custom-theme/code.tsx create mode 100644 __tests__/visitor/custom-theme/options.json create mode 100644 __tests__/visitor/custom-theme/output.tsx create mode 100644 __tests__/visitor/custom-theme/tailwind.config.js create mode 100644 src/utils/with-platform-theme.ts 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 + ); +}