From 24fb82a713416d395ce4f25b776242b75db4a0b3 Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:45:03 -0400 Subject: [PATCH 1/6] fix: preserve opacity in scheme modifiers --- docs/src/content/docs/guides/color-scheme.md | 15 ++++ src/parser/colors.test.ts | 17 ++++ src/parser/colors.ts | 18 ++--- src/parser/modifiers.test.ts | 82 ++++++++++++++++++++ src/parser/modifiers.ts | 11 ++- src/utils/colorUtils.test.ts | 60 +++++++++++++- src/utils/colorUtils.ts | 63 +++++++++++---- 7 files changed, 239 insertions(+), 27 deletions(-) diff --git a/docs/src/content/docs/guides/color-scheme.md b/docs/src/content/docs/guides/color-scheme.md index 5080cb6..9a74102 100644 --- a/docs/src/content/docs/guides/color-scheme.md +++ b/docs/src/content/docs/guides/color-scheme.md @@ -172,6 +172,21 @@ The `scheme:` modifier only works with color utilities: - ✅ `scheme:border-{color}` — Border colors - ❌ Other utilities — Ignored with development warning +Opacity modifiers are preserved when the semantic color expands: + +```tsx + +// dark:bg-primary-dark/25 light:bg-primary-light/25 + + +// dark:text-systemLabel-dark/80 light:text-systemLabel-light/80 + + +// dark:bg-primary-dark/[.37] light:bg-primary-light/[.37] +``` + +Named opacity modifiers use percentages from `/0` through `/100`. Arbitrary modifiers accept a raw alpha such as `/[.37]` or an explicit percentage such as `/[37%]`. If a configured color already includes an alpha channel, the modifier composes with that alpha instead of producing an invalid color. + ### Use Cases **Semantic color names:** diff --git a/src/parser/colors.test.ts b/src/parser/colors.test.ts index 15b8bd6..29c59c7 100644 --- a/src/parser/colors.test.ts +++ b/src/parser/colors.test.ts @@ -320,6 +320,21 @@ describe("parseColor - opacity modifiers", () => { expect(parseColor("border-[#abc]/60")).toEqual({ borderColor: "#AABBCC99" }); }); + it("should handle arbitrary raw and percentage opacity modifiers", () => { + expect(parseColor("bg-black/[.37]")).toEqual({ backgroundColor: "#0000005E" }); + expect(parseColor("text-white/[37%]")).toEqual({ color: "#FFFFFF5E" }); + expect(parseColor("border-red-500/[.5]")).toEqual({ + borderColor: applyOpacity(COLORS["red-500"], 50), + }); + }); + + it("should multiply opacity for colors with an existing alpha channel", () => { + expect(parseColor("bg-[#edecf7af]/25")).toEqual({ backgroundColor: "#EDECF72C" }); + expect(parseColor("text-translucent/[.5]", { translucent: "#11223380" })).toEqual({ + color: "#11223340", + }); + }); + it("should handle opacity modifier with custom colors", () => { const customColors = { "brand-primary": "#FF6B6B" }; expect(parseColor("bg-brand-primary/50", customColors)).toEqual({ backgroundColor: "#FF6B6B80" }); @@ -355,6 +370,8 @@ describe("parseColor - opacity modifiers", () => { expect(parseColor("bg-black/101")).toBeNull(); // > 100 expect(parseColor("bg-black/-1")).toBeNull(); // < 0 expect(parseColor("bg-black/150")).toBeNull(); // Way over 100 + expect(parseColor("bg-black/[1.1]")).toBeNull(); + expect(parseColor("bg-black/[101%]")).toBeNull(); }); it("should return null for malformed opacity syntax", () => { diff --git a/src/parser/colors.ts b/src/parser/colors.ts index 5631e17..7431fbc 100644 --- a/src/parser/colors.ts +++ b/src/parser/colors.ts @@ -3,7 +3,7 @@ */ import type { StyleObject } from "../types"; -import { COLORS, applyOpacity, parseArbitraryColor } from "../utils/colorUtils"; +import { COLORS, applyOpacity, parseArbitraryColor, parseColorOpacityModifier } from "../utils/colorUtils"; // Re-export COLORS for backward compatibility and tests export { COLORS }; @@ -21,18 +21,16 @@ export function parseColor(cls: string, customColors?: Record): // Helper to parse color with optional opacity modifier // Uses internal implementation to preserve warnings for invalid arbitrary colors const parseColorWithOpacity = (colorKey: string): string | null => { - // Check for opacity modifier: blue-500/50 - const opacityMatch = colorKey.match(/^(.+)\/(\d+)$/); - if (opacityMatch) { - const baseColorKey = opacityMatch[1]; - const opacity = Number.parseInt(opacityMatch[2], 10); - - // Validate opacity range (0-100) - if (opacity < 0 || opacity > 100) { + // Check for opacity modifier: blue-500/50, blue-500/[.5], blue-500/[50%] + const opacityModifier = parseColorOpacityModifier(colorKey); + if (opacityModifier) { + const { baseColorKey, opacity } = opacityModifier; + if (opacity === null) { /* v8 ignore next 5 */ if (process.env.NODE_ENV !== "production") { console.warn( - `[react-native-tailwind] Invalid opacity value: ${opacity}. Opacity must be between 0 and 100.`, + `[react-native-tailwind] Invalid opacity modifier: ${opacityModifier.suffix}. ` + + "Use an integer percentage from 0 to 100, a raw arbitrary alpha from 0 to 1, or an arbitrary percentage.", ); } return null; diff --git a/src/parser/modifiers.test.ts b/src/parser/modifiers.test.ts index ca4fe63..afc0aab 100644 --- a/src/parser/modifiers.test.ts +++ b/src/parser/modifiers.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import { applyOpacity } from "../utils/colorUtils"; +import { parseColor } from "./colors"; import type { ParsedModifier } from "./modifiers"; import { expandSchemeModifier, @@ -482,6 +484,73 @@ describe("expandSchemeModifier", () => { }); }); + it("should preserve opacity modifiers when expanding scheme colors", () => { + const modifier = { modifier: "scheme" as const, baseClass: "bg-primary/25" }; + const result = expandSchemeModifier(modifier, customColors); + + expect(result).toEqual([ + { modifier: "dark", baseClass: "bg-primary-dark/25" }, + { modifier: "light", baseClass: "bg-primary-light/25" }, + ]); + }); + + it("should produce parsable color variants with the requested opacity", () => { + const [dark, light] = expandSchemeModifier( + { modifier: "scheme", baseClass: "bg-primary/25" }, + customColors, + ); + + expect(parseColor(dark.baseClass, customColors)).toEqual({ + backgroundColor: applyOpacity(customColors["primary-dark"], 25), + }); + expect(parseColor(light.baseClass, customColors)).toEqual({ + backgroundColor: applyOpacity(customColors["primary-light"], 25), + }); + }); + + it("should preserve arbitrary opacity modifiers when expanding scheme colors", () => { + const result = expandSchemeModifier({ modifier: "scheme", baseClass: "bg-primary/[.37]" }, customColors); + + expect(result).toEqual([ + { modifier: "dark", baseClass: "bg-primary-dark/[.37]" }, + { modifier: "light", baseClass: "bg-primary-light/[.37]" }, + ]); + expect(parseColor(result[0].baseClass, customColors)).toEqual({ + backgroundColor: applyOpacity(customColors["primary-dark"], 37), + }); + }); + + it("should compose scheme opacity with configured colors that already have alpha", () => { + const colors = { + "glass-dark": "#11223380", + "glass-light": "#EDECF7AF", + }; + const [dark, light] = expandSchemeModifier({ modifier: "scheme", baseClass: "bg-glass/50" }, colors); + + expect(parseColor(dark.baseClass, colors)).toEqual({ backgroundColor: "#11223340" }); + expect(parseColor(light.baseClass, colors)).toEqual({ backgroundColor: "#EDECF758" }); + }); + + it("should preserve edge opacity values for every supported color prefix", () => { + expect(expandSchemeModifier({ modifier: "scheme", baseClass: "text-systemGray/0" }, customColors)).toEqual( + [ + { modifier: "dark", baseClass: "text-systemGray-dark/0" }, + { modifier: "light", baseClass: "text-systemGray-light/0" }, + ], + ); + expect(expandSchemeModifier({ modifier: "scheme", baseClass: "border-accent/100" }, customColors)).toEqual( + [ + { modifier: "dark", baseClass: "border-accent-dark/100" }, + { modifier: "light", baseClass: "border-accent-light/100" }, + ], + ); + }); + + it("should validate scheme variants without including the opacity suffix", () => { + const modifier = { modifier: "scheme" as const, baseClass: "bg-missing/25" }; + expect(expandSchemeModifier(modifier, customColors)).toEqual([]); + }); + it("should use custom suffixes when provided", () => { const modifier = { modifier: "scheme" as const, baseClass: "text-systemGray" }; const _result = expandSchemeModifier(modifier, customColors, "-darkMode", "-lightMode"); @@ -494,6 +563,19 @@ describe("expandSchemeModifier", () => { expect(expandSchemeModifier(modifier, expectedColors, "-darkMode", "-lightMode")).toHaveLength(2); }); + it("should combine opacity with custom scheme suffixes", () => { + const modifier = { modifier: "scheme" as const, baseClass: "text-systemGray/50" }; + const colors = { + "systemGray-night": "#333333", + "systemGray-day": "#CCCCCC", + }; + + expect(expandSchemeModifier(modifier, colors, "-night", "-day")).toEqual([ + { modifier: "dark", baseClass: "text-systemGray-night/50" }, + { modifier: "light", baseClass: "text-systemGray-day/50" }, + ]); + }); + it("should return empty array for non-color classes", () => { const modifier = { modifier: "scheme" as const, baseClass: "m-4" }; const result = expandSchemeModifier(modifier, customColors); diff --git a/src/parser/modifiers.ts b/src/parser/modifiers.ts index d2d7eab..7111e1e 100644 --- a/src/parser/modifiers.ts +++ b/src/parser/modifiers.ts @@ -6,6 +6,8 @@ * - Directional modifiers: rtl:, ltr: (RTL-aware styling) */ +import { parseColorOpacityModifier } from "../utils/colorUtils"; + export type StateModifierType = "active" | "hover" | "focus" | "disabled" | "placeholder"; export type PlatformModifierType = "ios" | "android" | "web"; export type ColorSchemeModifierType = "dark" | "light"; @@ -225,7 +227,10 @@ export function expandSchemeModifier( return []; } - const [, prefix, colorName] = match; + const [, prefix, colorWithOpacity] = match; + const opacityModifier = parseColorOpacityModifier(colorWithOpacity); + const colorName = opacityModifier?.baseColorKey ?? colorWithOpacity; + const opacitySuffix = opacityModifier?.suffix ?? ""; // Build variant class names const darkColorName = `${colorName}${darkSuffix}`; @@ -252,11 +257,11 @@ export function expandSchemeModifier( return [ { modifier: "dark" as ColorSchemeModifierType, - baseClass: `${prefix}-${darkColorName}`, + baseClass: `${prefix}-${darkColorName}${opacitySuffix}`, }, { modifier: "light" as ColorSchemeModifierType, - baseClass: `${prefix}-${lightColorName}`, + baseClass: `${prefix}-${lightColorName}${opacitySuffix}`, }, ]; } diff --git a/src/utils/colorUtils.test.ts b/src/utils/colorUtils.test.ts index 4e1e08a..08f22e7 100644 --- a/src/utils/colorUtils.test.ts +++ b/src/utils/colorUtils.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { COLORS, applyOpacity, parseArbitraryColor, parseColorValue } from "./colorUtils"; +import { + COLORS, + applyOpacity, + parseArbitraryColor, + parseColorOpacityModifier, + parseColorValue, +} from "./colorUtils"; describe("COLORS", () => { it("should include basic colors", () => { @@ -31,6 +37,12 @@ describe("applyOpacity", () => { expect(applyOpacity("#00f", 75)).toBe("#0000FFBF"); }); + it("should multiply existing alpha on 8-digit hex colors", () => { + expect(applyOpacity("#ff000080", 50)).toBe("#FF000040"); + expect(applyOpacity("#edecf7af", 25)).toBe("#EDECF72C"); + expect(applyOpacity("#edecf7af", 100)).toBe("#EDECF7AF"); + }); + it("should handle various opacity values", () => { expect(applyOpacity("#000000", 0)).toBe("#00000000"); expect(applyOpacity("#000000", 25)).toBe("#00000040"); @@ -52,6 +64,39 @@ describe("applyOpacity", () => { }); }); +describe("parseColorOpacityModifier", () => { + it("should parse named percentages and arbitrary alpha values", () => { + expect(parseColorOpacityModifier("card/35")).toEqual({ + baseColorKey: "card", + opacity: 35, + suffix: "/35", + }); + expect(parseColorOpacityModifier("card/[.37]")).toEqual({ + baseColorKey: "card", + opacity: 37, + suffix: "/[.37]", + }); + expect(parseColorOpacityModifier("card/[37%]")).toEqual({ + baseColorKey: "card", + opacity: 37, + suffix: "/[37%]", + }); + }); + + it("should preserve recognized but out-of-range modifiers for validation", () => { + expect(parseColorOpacityModifier("card/101")).toEqual({ + baseColorKey: "card", + opacity: null, + suffix: "/101", + }); + expect(parseColorOpacityModifier("card/[1.1]")).toEqual({ + baseColorKey: "card", + opacity: null, + suffix: "/[1.1]", + }); + }); +}); + describe("parseArbitraryColor", () => { it("should parse 6-digit hex colors", () => { expect(parseArbitraryColor("[#ff0000]")).toBe("#ff0000"); @@ -144,6 +189,17 @@ describe("parseColorValue", () => { expect(parseColorValue("[#0000ff]/80")).toBe("#0000FFCC"); }); + it("should apply arbitrary raw and percentage opacity values", () => { + expect(parseColorValue("red-500/[.37]")).toBe(applyOpacity(COLORS["red-500"], 37)); + expect(parseColorValue("black/[37%]")).toBe("#0000005E"); + expect(parseColorValue("[#ff0000]/[.5]")).toBe("#FF000080"); + }); + + it("should compose opacity with colors that already have alpha", () => { + expect(parseColorValue("[#ff000080]/50")).toBe("#FF000040"); + expect(parseColorValue("translucent/[.25]", { translucent: "#edecf7af" })).toBe("#EDECF72C"); + }); + it("should handle edge opacity values", () => { expect(parseColorValue("red-500/0")).toBe(applyOpacity(COLORS["red-500"], 0)); expect(parseColorValue("red-500/100")).toBe(applyOpacity(COLORS["red-500"], 100)); @@ -153,6 +209,8 @@ describe("parseColorValue", () => { expect(parseColorValue("red-500/101")).toBeNull(); expect(parseColorValue("red-500/-1")).toBeNull(); expect(parseColorValue("red-500/abc")).toBeNull(); + expect(parseColorValue("red-500/[1.1]")).toBeNull(); + expect(parseColorValue("red-500/[101%]")).toBeNull(); }); it("should keep transparent unchanged with opacity", () => { diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index 27ecbca..9740cc7 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -16,8 +16,9 @@ export const COLORS: Record = { }; /** - * Apply opacity to hex color by appending alpha channel - * @param hex - Hex color string (e.g., "#ff0000", "#f00", or "transparent") + * Apply opacity to a hex color. Existing alpha is multiplied, matching + * Tailwind's color-mix-with-transparent semantics. + * @param hex - Hex color string (e.g., "#ff0000", "#f00", "#ff000080", or "transparent") * @param opacity - Opacity value 0-100 (e.g., 50 for 50%) * @returns 8-digit hex with alpha (e.g., "#FF000080") or transparent */ @@ -27,7 +28,7 @@ export function applyOpacity(hex: string, opacity: number): string { } const cleanHex = hex.replace(/^#/, ""); - const fullHex = + const normalizedHex = cleanHex.length === 3 ? cleanHex .split("") @@ -35,10 +36,49 @@ export function applyOpacity(hex: string, opacity: number): string { .join("") : cleanHex; - const alpha = Math.round((opacity / 100) * 255); + const rgbHex = normalizedHex.slice(0, 6); + const existingAlpha = normalizedHex.length === 8 ? Number.parseInt(normalizedHex.slice(6), 16) : 255; + const alpha = Math.round((opacity / 100) * existingAlpha); const alphaHex = alpha.toString(16).padStart(2, "0").toUpperCase(); - return `#${fullHex.toUpperCase()}${alphaHex}`; + return `#${rgbHex.toUpperCase()}${alphaHex}`; +} + +export type ColorOpacityModifier = { + baseColorKey: string; + opacity: number | null; + suffix: string; +}; + +const COLOR_OPACITY_MODIFIER_PATTERN = /^(.+)\/(\d+|\[((?:\d+(?:\.\d*)?|\.\d+)(?:%)?)\])$/; + +/** + * Split and parse a Tailwind color opacity modifier. + * Named values are percentages (`/50`), while arbitrary values accept a raw + * alpha (`/[.5]`) or an explicit percentage (`/[50%]`). + */ +export function parseColorOpacityModifier(colorKey: string): ColorOpacityModifier | null { + const match = colorKey.match(COLOR_OPACITY_MODIFIER_PATTERN); + if (!match) { + return null; + } + + const [, baseColorKey, modifier, arbitraryValue] = match; + let opacity: number; + + if (arbitraryValue === undefined) { + opacity = Number.parseInt(modifier, 10); + } else if (arbitraryValue.endsWith("%")) { + opacity = Number.parseFloat(arbitraryValue.slice(0, -1)); + } else { + opacity = Number.parseFloat(arbitraryValue) * 100; + } + + return { + baseColorKey, + opacity: Number.isFinite(opacity) && opacity >= 0 && opacity <= 100 ? opacity : null, + suffix: `/${modifier}`, + }; } /** @@ -77,14 +117,11 @@ export function parseColorValue(colorKey: string, customColors?: Record 100) { + // Check for opacity modifier: red-500/50, red-500/[.5], [#ff0000]/[50%] + const opacityModifier = parseColorOpacityModifier(colorKey); + if (opacityModifier) { + const { baseColorKey, opacity } = opacityModifier; + if (opacity === null) { return null; } From 2ed0bfd035c88649ccdbe50678ce82bfde4d0783 Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:23:52 -0500 Subject: [PATCH 2/6] Add color scheme modifier for outline --- docs/src/content/docs/guides/color-scheme.md | 1 + docs/src/content/docs/reference/outlines.md | 2 + src/parser/modifiers.test.ts | 62 ++++++++++++++++++++ src/parser/modifiers.ts | 15 ++++- 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/guides/color-scheme.md b/docs/src/content/docs/guides/color-scheme.md index 9a74102..9c72784 100644 --- a/docs/src/content/docs/guides/color-scheme.md +++ b/docs/src/content/docs/guides/color-scheme.md @@ -170,6 +170,7 @@ The `scheme:` modifier only works with color utilities: - ✅ `scheme:text-{color}` — Text colors - ✅ `scheme:bg-{color}` — Background colors - ✅ `scheme:border-{color}` — Border colors +- ✅ `scheme:outline-{color}` — Outline colors - ❌ Other utilities — Ignored with development warning Opacity modifiers are preserved when the semantic color expands: diff --git a/docs/src/content/docs/reference/outlines.md b/docs/src/content/docs/reference/outlines.md index 7593e08..8367b36 100644 --- a/docs/src/content/docs/reference/outlines.md +++ b/docs/src/content/docs/reference/outlines.md @@ -24,6 +24,8 @@ Utilities for controlling the outline style of an element. // outlineColor: '#3B82F6' // outlineColor: '#ff0000' // outlineColor: '#EF4444' (50% opacity) + // Theme-aware outline color + // Expands to light/dark variants ``` ## Outline Style diff --git a/src/parser/modifiers.test.ts b/src/parser/modifiers.test.ts index afc0aab..d9493e3 100644 --- a/src/parser/modifiers.test.ts +++ b/src/parser/modifiers.test.ts @@ -420,6 +420,23 @@ describe("isColorClass", () => { expect(isColorClass("border-black")).toBe(true); }); + it("should return true for outline color classes", () => { + expect(isColorClass("outline-red-500")).toBe(true); + expect(isColorClass("outline-systemGray")).toBe(true); + expect(isColorClass("outline-black")).toBe(true); + }); + + it("should return false for non-color outline classes", () => { + expect(isColorClass("outline-none")).toBe(false); + expect(isColorClass("outline-solid")).toBe(false); + expect(isColorClass("outline-dashed")).toBe(false); + expect(isColorClass("outline-dotted")).toBe(false); + expect(isColorClass("outline-hidden")).toBe(false); + expect(isColorClass("outline-2")).toBe(false); + expect(isColorClass("outline-offset-2")).toBe(false); + expect(isColorClass("outline-[3px]")).toBe(false); + }); + it("should return false for non-color classes", () => { expect(isColorClass("m-4")).toBe(false); expect(isColorClass("p-2")).toBe(false); @@ -551,6 +568,51 @@ describe("expandSchemeModifier", () => { expect(expandSchemeModifier(modifier, customColors)).toEqual([]); }); + it("should expand outline color scheme modifier", () => { + const modifier = { modifier: "scheme" as const, baseClass: "outline-primary" }; + const result = expandSchemeModifier(modifier, customColors); + + expect(result).toHaveLength(2); + expect((result as [ParsedModifier, ParsedModifier])[0]).toEqual({ + modifier: "dark", + baseClass: "outline-primary-dark", + }); + expect((result as [ParsedModifier, ParsedModifier])[1]).toEqual({ + modifier: "light", + baseClass: "outline-primary-light", + }); + }); + + it("should produce parsable outline color variants", () => { + const [dark, light] = expandSchemeModifier( + { modifier: "scheme", baseClass: "outline-primary" }, + customColors, + ); + + expect(parseColor(dark.baseClass, customColors)).toEqual({ outlineColor: "#1E40AF" }); + expect(parseColor(light.baseClass, customColors)).toEqual({ outlineColor: "#BFDBFE" }); + }); + + it("should preserve integer and arbitrary opacity for scheme outline colors", () => { + expect( + expandSchemeModifier({ modifier: "scheme", baseClass: "outline-primary/25" }, customColors), + ).toEqual([ + { modifier: "dark", baseClass: "outline-primary-dark/25" }, + { modifier: "light", baseClass: "outline-primary-light/25" }, + ]); + + const [dark, light] = expandSchemeModifier( + { modifier: "scheme", baseClass: "outline-primary/[.37]" }, + customColors, + ); + expect(parseColor(dark.baseClass, customColors)).toEqual({ + outlineColor: applyOpacity(customColors["primary-dark"], 37), + }); + expect(parseColor(light.baseClass, customColors)).toEqual({ + outlineColor: applyOpacity(customColors["primary-light"], 37), + }); + }); + it("should use custom suffixes when provided", () => { const modifier = { modifier: "scheme" as const, baseClass: "text-systemGray" }; const _result = expandSchemeModifier(modifier, customColors, "-darkMode", "-lightMode"); diff --git a/src/parser/modifiers.ts b/src/parser/modifiers.ts index 7111e1e..e36231b 100644 --- a/src/parser/modifiers.ts +++ b/src/parser/modifiers.ts @@ -174,9 +174,18 @@ export function isDirectionalModifier(modifier: ModifierType): modifier is Direc * Check if a class name is a color-based utility class * * @param className - Class name to check - * @returns true if class is color-based (text-*, bg-*, border-*) + * @returns true if class is color-based (text-*, bg-*, border-*, outline-*) */ export function isColorClass(className: string): boolean { + if (className.startsWith("outline-")) { + const value = className.substring(8); + const isStyle = ["solid", "dashed", "dotted", "none", "hidden"].includes(value); + const isWidthOrOffset = /^\d/.test(value) || value.startsWith("offset-"); + const isNonColorArbitrary = value.startsWith("[") && !value.startsWith("[#"); + + return !isStyle && !isWidthOrOffset && !isNonColorArbitrary; + } + return className.startsWith("text-") || className.startsWith("bg-") || className.startsWith("border-"); } @@ -213,7 +222,7 @@ export function expandSchemeModifier( if (!isColorClass(baseClass)) { if (process.env.NODE_ENV !== "production") { console.warn( - `[react-native-tailwind] scheme: modifier only supports color classes (text-*, bg-*, border-*). ` + + `[react-native-tailwind] scheme: modifier only supports color classes (text-*, bg-*, border-*, outline-*). ` + `Found: "${baseClass}". This modifier will be ignored.`, ); } @@ -222,7 +231,7 @@ export function expandSchemeModifier( // Extract the color name from the class // e.g., "text-systemGray" -> "systemGray" - const match = baseClass.match(/^(text|bg|border)-(.+)$/); + const match = baseClass.match(/^(text|bg|border|outline)-(.+)$/); if (!match) { return []; } From 08d12b8abecb65402372b3e23c31da5b0db53645 Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:42:54 -0400 Subject: [PATCH 3/6] feat: add compile-time raw color helpers --- docs/src/content/docs/reference/api.md | 47 +++++- src/babel/plugin.ts | 5 + src/babel/plugin/state.ts | 4 + src/babel/plugin/visitors/imports.ts | 2 + src/babel/plugin/visitors/program.ts | 4 + src/babel/plugin/visitors/twColor.test.ts | 172 ++++++++++++++++++++++ src/babel/plugin/visitors/twColor.ts | 94 ++++++++++++ src/babel/utils/preInjection.ts | 26 +++- src/babel/utils/twColorProcessing.test.ts | 42 ++++++ src/babel/utils/twColorProcessing.ts | 161 ++++++++++++++++++++ src/index.ts | 1 + src/stubs/twColor.test.ts | 17 +++ src/stubs/twColor.ts | 20 +++ 13 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 src/babel/plugin/visitors/twColor.test.ts create mode 100644 src/babel/plugin/visitors/twColor.ts create mode 100644 src/babel/utils/twColorProcessing.test.ts create mode 100644 src/babel/utils/twColorProcessing.ts create mode 100644 src/stubs/twColor.test.ts create mode 100644 src/stubs/twColor.ts diff --git a/docs/src/content/docs/reference/api.md b/docs/src/content/docs/reference/api.md index fe5d564..d598a17 100644 --- a/docs/src/content/docs/reference/api.md +++ b/docs/src/content/docs/reference/api.md @@ -5,6 +5,51 @@ description: Access the parser and constants programmatically Access the parser and constants programmatically for advanced use cases. +## Compile-Time Raw Colors + +Use `useTwColor` when a React Native API needs a color string instead of a `style` object, such as navigation options, gradients, SVG, or Skia: + +```tsx +import { useTwColor, useTwColors } from "@mgcrea/react-native-tailwind"; + +function Header() { + const tintColor = useTwColor("scheme:accent"); + + return ; +} +``` + +Raw tokens use the same configured palette as color classes. Prefix a token with `scheme:` to resolve its configured light and dark variants reactively: + +```tsx +const card = useTwColor("scheme:card"); +const translucentBlue = useTwColor("blue-500/35"); +const customHex = useTwColor("[#50d71e]"); +``` + +The Babel plugin replaces each call with literal color strings. Scheme tokens reuse the plugin's configured `colorScheme` hook, including custom application theme hooks. +When that hook returns `"dark"`, the dark variant is used; `"light"`, `null`, and other non-dark values use the light variant, matching the default light appearance before a dark preference is active. + +Use `useTwColors` to resolve several named colors with one injected scheme hook: + +```tsx +function Screen() { + const colors = useTwColors({ + background: "scheme:background", + text: "scheme:text", + accent: "accent", + }); + + return ( + + Hello + + ); +} +``` + +Both helpers require static string literals and must be called unconditionally inside a function component. Unknown colors, unsupported modifiers, dynamic expressions, and module-scope calls produce a compile error. Utility-form tokens such as `bg-card` and `text-accent` are accepted, but raw palette names are preferred when the consumer needs only a string. + ## parseClassName Parse className strings to React Native styles: @@ -166,7 +211,7 @@ function ThemedComponent() { ## Important Notes -- The programmatic API parses styles at **runtime**, not compile-time +- `useTwColor` and `useTwColors` are compile-time APIs; `parseClassName` parses styles at runtime - For production apps, prefer using `className` prop for compile-time optimization - Use the programmatic API for: - Testing diff --git a/src/babel/plugin.ts b/src/babel/plugin.ts index 078f8c6..5c5b7da 100644 --- a/src/babel/plugin.ts +++ b/src/babel/plugin.ts @@ -13,6 +13,7 @@ import { jsxAttributeVisitor } from "./plugin/visitors/className.js"; import { importDeclarationVisitor } from "./plugin/visitors/imports.js"; import { programEnter, programExit } from "./plugin/visitors/program.js"; import { callExpressionVisitor, taggedTemplateVisitor } from "./plugin/visitors/tw.js"; +import { twColorCallExpressionVisitor } from "./plugin/visitors/twColor.js"; import { scanForColorSchemeModifiers } from "./utils/preInjection.js"; import { injectColorSchemeHook } from "./utils/styleInjection.js"; @@ -80,6 +81,8 @@ export default function reactNativeTailwindBabelPlugin( const importedName = spec.imported.name; if (importedName === "tw" || importedName === "twStyle") { state.twImportNames.add(spec.local.name); + } else if (importedName === "useTwColor" || importedName === "useTwColors") { + state.twColorImportNames.set(spec.local.name, importedName); } } } @@ -100,6 +103,7 @@ export default function reactNativeTailwindBabelPlugin( state.attributePatterns, state.twImportNames, t, + new Set(state.twColorImportNames.keys()), ) ) { injectColorSchemeHook( @@ -134,6 +138,7 @@ export default function reactNativeTailwindBabelPlugin( CallExpression(path, state) { callExpressionVisitor(path, state, t); + twColorCallExpressionVisitor(path, state, t); }, JSXAttribute(path, state) { diff --git a/src/babel/plugin/state.ts b/src/babel/plugin/state.ts index 91d8971..946fff1 100644 --- a/src/babel/plugin/state.ts +++ b/src/babel/plugin/state.ts @@ -116,6 +116,8 @@ export type PluginState = PluginPass & { // Track tw/twStyle imports from main package twImportNames: Set; // e.g., ['tw', 'twStyle'] or ['tw as customTw'] hasTwImport: boolean; + twColorImportNames: Map; + hasTwColorImport: boolean; // Track react-native import path for conditional StyleSheet/Platform injection reactNativeImportPath?: NodePath; // Track function components that need colorScheme hook injection @@ -179,6 +181,8 @@ export function createInitialState( stylesIdentifier, twImportNames: new Set(), hasTwImport: false, + twColorImportNames: new Map(), + hasTwColorImport: false, reactNativeImportPath: undefined, functionComponentsNeedingColorScheme: new Set(), functionComponentsNeedingWindowDimensions: new Set(), diff --git a/src/babel/plugin/visitors/imports.ts b/src/babel/plugin/visitors/imports.ts index 3dad7f2..d1118ab 100644 --- a/src/babel/plugin/visitors/imports.ts +++ b/src/babel/plugin/visitors/imports.ts @@ -110,6 +110,8 @@ export function importDeclarationVisitor( const localName = spec.local.name; state.twImportNames.add(localName); // Don't set hasTwImport yet - only set it when we successfully transform a call + } else if (importedName === "useTwColor" || importedName === "useTwColors") { + state.twColorImportNames.set(spec.local.name, importedName); } } }); diff --git a/src/babel/plugin/visitors/program.ts b/src/babel/plugin/visitors/program.ts index 251c474..f8d4919 100644 --- a/src/babel/plugin/visitors/program.ts +++ b/src/babel/plugin/visitors/program.ts @@ -16,6 +16,7 @@ import { injectStylesAtTop, injectWindowDimensionsHook, } from "../../utils/styleInjection.js"; +import { removeTwColorImports } from "../../utils/twColorProcessing.js"; import { removeTwImports } from "../../utils/twProcessing.js"; import type { PluginState } from "../state.js"; @@ -41,6 +42,9 @@ export function programExit( if (state.hasTwImport) { removeTwImports(path, t); } + if (state.hasTwColorImport) { + removeTwColorImports(path, t); + } // If no classNames were found and no hooks/imports needed, skip processing if ( diff --git a/src/babel/plugin/visitors/twColor.test.ts b/src/babel/plugin/visitors/twColor.test.ts new file mode 100644 index 0000000..ed1ea8f --- /dev/null +++ b/src/babel/plugin/visitors/twColor.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; + +import { transform } from "../../../../test/helpers/babelTransform.js"; +import { COLORS } from "../../../parser/colors.js"; + +describe("raw color hooks", () => { + it("should compile a static color token to a string literal", () => { + const output = transform(` + import { useTwColor } from '@mgcrea/react-native-tailwind'; + export function Component() { + const color = useTwColor('blue-500'); + return color; + } + `); + + expect(output).toContain(`const color = "${COLORS["blue-500"]}"`); + expect(output).not.toContain("useTwColor"); + expect(output).not.toContain("useColorScheme"); + }); + + it("should support utility-form tokens, opacity, and arbitrary hex", () => { + const output = transform(` + import { useTwColors } from '@mgcrea/react-native-tailwind'; + export function Component() { + return useTwColors({ + background: 'bg-red-500/35', + foreground: 'text-[#abcdef]', + }); + } + `); + + expect(output).toContain('background: "#FB2C3659"'); + expect(output).toContain('foreground: "#abcdef"'); + expect(output).not.toContain("useTwColors"); + }); + + it("should compile a scheme token with the configured hook", () => { + const output = transform( + ` + import { useTwColor } from '@mgcrea/react-native-tailwind'; + export function Component() { + return useTwColor('scheme:gray'); + } + `, + { + colorScheme: { + importFrom: "@/theme/useColorScheme", + importName: "useAppColorScheme", + }, + schemeModifier: { + darkSuffix: "-900", + lightSuffix: "-100", + }, + }, + ); + + expect(output).toContain('from "@/theme/useColorScheme"'); + expect(output).toContain("useAppColorScheme()"); + expect(output).toContain('_twColorScheme === "dark"'); + expect(output).toContain(`? "${COLORS["gray-900"]}"`); + expect(output).toContain(`: "${COLORS["gray-100"]}"`); + }); + + it("should inject one scheme hook for a color object", () => { + const output = transform( + ` + import { useTwColors } from '@mgcrea/react-native-tailwind'; + export function Component() { + return useTwColors({ + background: 'scheme:gray', + foreground: 'scheme:slate', + accent: 'blue-500', + }); + } + `, + { + schemeModifier: { + darkSuffix: "-900", + lightSuffix: "-100", + }, + }, + ); + + expect(output.match(/_twColorScheme\s*=\s*useColorScheme\(\)/g)).toHaveLength(1); + expect(output).toContain("background:"); + expect(output).toContain("foreground:"); + expect(output).toContain("accent:"); + }); + + it("should pre-inject the scheme hook for a concise arrow component", () => { + const output = transform( + ` + import { useTwColor } from '@mgcrea/react-native-tailwind'; + export const Component = () => useTwColor('scheme:gray'); + `, + { + schemeModifier: { + darkSuffix: "-900", + lightSuffix: "-100", + }, + }, + ); + + expect(output.match(/_twColorScheme\s*=\s*useColorScheme\(\)/g)).toHaveLength(1); + expect(output).toContain('_twColorScheme === "dark"'); + expect(output).toContain(`? "${COLORS["gray-900"]}"`); + expect(output).toContain(`: "${COLORS["gray-100"]}"`); + }); + + it("should transform aliased imports and preserve unrelated imports", () => { + const output = transform(` + import { parseColor, useTwColor as useColor } from '@mgcrea/react-native-tailwind'; + export function Component() { + return [useColor('black'), parseColor]; + } + `); + + expect(output).toContain("parseColor"); + expect(output).not.toContain("useTwColor"); + expect(output).not.toContain("useColor as"); + expect(output).toContain('"#000000"'); + }); + + it("should reject unknown, dynamic, and module-scope tokens", () => { + expect(() => + transform(` + import { useTwColor } from '@mgcrea/react-native-tailwind'; + export function Component() { + return useTwColor('not-a-real-color'); + } + `), + ).toThrow(/Unknown or unsupported color token/); + + expect(() => + transform(` + import { useTwColor } from '@mgcrea/react-native-tailwind'; + export function Component({ token }) { + return useTwColor(token); + } + `), + ).toThrow(/requires a static string literal/); + + expect(() => + transform( + ` + import { useTwColor } from '@mgcrea/react-native-tailwind'; + export const color = useTwColor('scheme:gray'); + `, + { schemeModifier: { darkSuffix: "-900", lightSuffix: "-100" } }, + ), + ).toThrow(/must be called inside a React function component/); + + expect(() => + transform(` + import { useTwColor } from '@mgcrea/react-native-tailwind'; + export const color = useTwColor('blue-500'); + `), + ).toThrow(/must be called inside a React function component/); + }); + + it("should reject runtime references to compile-only color helpers", () => { + expect(() => + transform(` + import { useTwColor } from '@mgcrea/react-native-tailwind'; + const resolveColor = useTwColor; + export function Component() { + return useTwColor('blue-500'); + } + `), + ).toThrow(/must be called directly/); + }); +}); diff --git a/src/babel/plugin/visitors/twColor.ts b/src/babel/plugin/visitors/twColor.ts new file mode 100644 index 0000000..1083a00 --- /dev/null +++ b/src/babel/plugin/visitors/twColor.ts @@ -0,0 +1,94 @@ +/** CallExpression visitor for compile-time raw color hooks. */ + +import type { NodePath } from "@babel/core"; +import type * as BabelTypes from "@babel/types"; + +import { + assertTwColorComponentScope, + ensureTwColorSchemeHook, + resolveTwColorToken, + twColorTokenToExpression, +} from "../../utils/twColorProcessing.js"; +import type { ResolvedTwColorToken } from "../../utils/twColorProcessing.js"; +import type { PluginState } from "../state.js"; + +function invalidCall(path: NodePath, message: string): never { + throw path.buildCodeFrameError(`[react-native-tailwind] ${message}`); +} + +export function twColorCallExpressionVisitor( + path: NodePath, + state: PluginState, + t: typeof BabelTypes, +): void { + if (!t.isIdentifier(path.node.callee)) { + return; + } + + const helper = state.twColorImportNames.get(path.node.callee.name); + if (!helper) { + return; + } + + if (path.node.arguments.length !== 1) { + invalidCall(path, `${helper}() expects exactly one argument.`); + } + + assertTwColorComponentScope(path, t); + + const argument = path.node.arguments[0]; + let needsScheme = false; + + if (helper === "useTwColor") { + if (!t.isStringLiteral(argument)) { + invalidCall(path, "useTwColor() requires a static string literal."); + } + + const token = resolveTwColorToken(argument.value, state); + if (!token) { + invalidCall(path, `Unknown or unsupported color token: "${argument.value}".`); + } + + needsScheme = token.kind === "scheme"; + if (needsScheme) { + ensureTwColorSchemeHook(path, state, t); + } + path.replaceWith(twColorTokenToExpression(token, state, t)); + } else { + if (!t.isObjectExpression(argument)) { + invalidCall(path, "useTwColors() requires an object literal of static color tokens."); + } + + const properties: BabelTypes.ObjectProperty[] = []; + const tokens: Array<{ property: BabelTypes.ObjectProperty; token: ResolvedTwColorToken }> = []; + + for (const property of argument.properties) { + if ( + !t.isObjectProperty(property) || + property.computed || + (!t.isIdentifier(property.key) && !t.isStringLiteral(property.key)) || + !t.isStringLiteral(property.value) + ) { + invalidCall(path, "useTwColors() only supports plain object properties with static string values."); + } + + const token = resolveTwColorToken(property.value.value, state); + if (!token) { + invalidCall(path, `Unknown or unsupported color token: "${property.value.value}".`); + } + needsScheme ||= token.kind === "scheme"; + tokens.push({ property, token }); + } + + if (needsScheme) { + ensureTwColorSchemeHook(path, state, t); + } + + for (const { property, token } of tokens) { + properties.push(t.objectProperty(t.cloneNode(property.key), twColorTokenToExpression(token, state, t))); + } + path.replaceWith(t.objectExpression(properties)); + } + + state.hasTwColorImport = true; +} diff --git a/src/babel/utils/preInjection.ts b/src/babel/utils/preInjection.ts index 99461ba..84384f9 100644 --- a/src/babel/utils/preInjection.ts +++ b/src/babel/utils/preInjection.ts @@ -26,8 +26,9 @@ export function scanForColorSchemeModifiers( attributePatterns: RegExp[], twImportNames: Set, t: typeof BabelTypes, + twColorImportNames: Set = new Set(), ): boolean { - return walkNode(node, supportedAttributes, attributePatterns, twImportNames, t); + return walkNode(node, supportedAttributes, attributePatterns, twImportNames, t, twColorImportNames); } function walkNode( @@ -36,6 +37,7 @@ function walkNode( attributePatterns: RegExp[], twImportNames: Set, t: typeof BabelTypes, + twColorImportNames: Set, ): boolean { // Check JSXAttribute with color scheme class names if (t.isJSXAttribute(node) && t.isJSXIdentifier(node.name)) { @@ -65,6 +67,24 @@ function walkNode( return true; } } + + if (twColorImportNames.has(node.callee.name)) { + const arg = node.arguments[0]; + if (t.isStringLiteral(arg) && COLOR_SCHEME_PATTERN.test(arg.value)) { + return true; + } + if (t.isObjectExpression(arg)) { + for (const property of arg.properties) { + if ( + t.isObjectProperty(property) && + t.isStringLiteral(property.value) && + COLOR_SCHEME_PATTERN.test(property.value.value) + ) { + return true; + } + } + } + } } // Walk children, skipping nested functions @@ -80,7 +100,7 @@ function walkNode( if (isASTNode(item)) { // Skip nested functions (they have their own scope) if (t.isFunction(item)) continue; - if (walkNode(item, supportedAttributes, attributePatterns, twImportNames, t)) { + if (walkNode(item, supportedAttributes, attributePatterns, twImportNames, t, twColorImportNames)) { return true; } } @@ -88,7 +108,7 @@ function walkNode( } else if (isASTNode(child)) { // Skip nested functions if (t.isFunction(child)) continue; - if (walkNode(child, supportedAttributes, attributePatterns, twImportNames, t)) { + if (walkNode(child, supportedAttributes, attributePatterns, twImportNames, t, twColorImportNames)) { return true; } } diff --git a/src/babel/utils/twColorProcessing.test.ts b/src/babel/utils/twColorProcessing.test.ts new file mode 100644 index 0000000..060c7f7 --- /dev/null +++ b/src/babel/utils/twColorProcessing.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import type { PluginState } from "../plugin/state"; +import { resolveTwColorToken } from "./twColorProcessing"; + +const state = { + customTheme: { + colors: { + card: "#ffffff", + "card-dark": "#222222", + "card-light": "#ffffff", + }, + fontFamily: {}, + fontSize: {}, + spacing: {}, + }, + schemeModifierConfig: { + darkSuffix: "-dark", + lightSuffix: "-light", + }, +} as unknown as PluginState; + +describe("resolveTwColorToken", () => { + it("should resolve custom and preset raw tokens", () => { + expect(resolveTwColorToken("card", state)).toEqual({ kind: "static", color: "#ffffff" }); + expect(resolveTwColorToken("black", state)).toEqual({ kind: "static", color: "#000000" }); + }); + + it("should resolve scheme variants from flattened theme colors", () => { + expect(resolveTwColorToken("scheme:card", state)).toEqual({ + kind: "scheme", + darkColor: "#222222", + lightColor: "#ffffff", + }); + }); + + it("should reject unsupported modifiers, multiple tokens, and unknown colors", () => { + expect(resolveTwColorToken("dark:card", state)).toBeNull(); + expect(resolveTwColorToken("card text", state)).toBeNull(); + expect(resolveTwColorToken("missing", state)).toBeNull(); + }); +}); diff --git a/src/babel/utils/twColorProcessing.ts b/src/babel/utils/twColorProcessing.ts new file mode 100644 index 0000000..47ad599 --- /dev/null +++ b/src/babel/utils/twColorProcessing.ts @@ -0,0 +1,161 @@ +/** Compile-time processing for raw Tailwind color tokens. */ + +import type { NodePath } from "@babel/core"; +import type * as BabelTypes from "@babel/types"; + +import { COLORS, parseColor } from "../../parser/colors.js"; +import { expandSchemeModifier } from "../../parser/modifiers.js"; +import { findComponentScope } from "../plugin/componentScope.js"; +import type { PluginState } from "../plugin/state.js"; +import { injectColorSchemeHook } from "./styleInjection.js"; + +export type ResolvedTwColorToken = + | { kind: "static"; color: string } + | { kind: "scheme"; darkColor: string; lightColor: string }; + +const COLOR_UTILITY_PATTERN = /^(?:bg|text|border|outline)-/; + +function normalizeColorUtility(token: string): string { + return COLOR_UTILITY_PATTERN.test(token) ? token : `text-${token}`; +} + +function extractNativeColor(style: ReturnType): string | null { + if (!style) { + return null; + } + + const colors = Object.entries(style) + .filter(([key, value]) => key.toLowerCase().includes("color") && typeof value === "string") + .map(([, value]) => value as string); + const uniqueColors = [...new Set(colors)]; + return uniqueColors.length === 1 ? uniqueColors[0] : null; +} + +function resolveUtilityColor(utility: string, state: PluginState): string | null { + return extractNativeColor(parseColor(utility, state.customTheme.colors)); +} + +export function resolveTwColorToken(token: string, state: PluginState): ResolvedTwColorToken | null { + const normalized = token.trim(); + if (!normalized || /\s/.test(normalized)) { + return null; + } + + if (normalized.startsWith("scheme:")) { + const utility = normalizeColorUtility(normalized.slice(7)); + const availableColors = { ...COLORS, ...state.customTheme.colors }; + const variants = expandSchemeModifier( + { modifier: "scheme", baseClass: utility }, + availableColors, + state.schemeModifierConfig.darkSuffix, + state.schemeModifierConfig.lightSuffix, + ); + + if (variants.length !== 2) { + return null; + } + + const darkColor = resolveUtilityColor(variants[0].baseClass, state); + const lightColor = resolveUtilityColor(variants[1].baseClass, state); + return darkColor && lightColor ? { kind: "scheme", darkColor, lightColor } : null; + } + + if (normalized.includes(":")) { + return null; + } + + const color = resolveUtilityColor(normalizeColorUtility(normalized), state); + return color ? { kind: "static", color } : null; +} + +export function twColorTokenToExpression( + token: ResolvedTwColorToken, + state: PluginState, + t: typeof BabelTypes, +): BabelTypes.Expression { + if (token.kind === "static") { + return t.stringLiteral(token.color); + } + + return t.conditionalExpression( + t.binaryExpression("===", t.identifier(state.colorSchemeVariableName), t.stringLiteral("dark")), + t.stringLiteral(token.darkColor), + t.stringLiteral(token.lightColor), + ); +} + +export function ensureTwColorSchemeHook( + path: NodePath, + state: PluginState, + t: typeof BabelTypes, +): void { + const componentScope = findComponentScope(path, t); + if (!componentScope) { + throw path.buildCodeFrameError( + "[react-native-tailwind] useTwColor/useTwColors must be called inside a React function component.", + ); + } + + state.functionComponentsNeedingColorScheme.add(componentScope); + state.needsColorSchemeImport = true; + injectColorSchemeHook( + componentScope, + state.colorSchemeVariableName, + state.colorSchemeHookName, + state.colorSchemeLocalIdentifier, + t, + ); +} + +export function assertTwColorComponentScope( + path: NodePath, + t: typeof BabelTypes, +): void { + if (!findComponentScope(path, t)) { + throw path.buildCodeFrameError( + "[react-native-tailwind] useTwColor/useTwColors must be called inside a React function component.", + ); + } +} + +export function removeTwColorImports(path: NodePath, t: typeof BabelTypes): void { + // Refresh bindings after call expressions have been replaced. If a helper is + // also referenced as a runtime value, removing its import would leave an + // unbound identifier in the generated module. + path.scope.crawl(); + + path.traverse({ + ImportDeclaration(importPath) { + if (importPath.node.source.value !== "@mgcrea/react-native-tailwind") { + return; + } + + const remainingSpecifiers = importPath.node.specifiers.filter((specifier) => { + if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported)) { + const isTwColorHelper = + specifier.imported.name === "useTwColor" || specifier.imported.name === "useTwColors"; + if (!isTwColorHelper) { + return true; + } + + const binding = path.scope.getBinding(specifier.local.name); + const remainingReference = binding?.referencePaths.find((referencePath) => !referencePath.removed); + if (remainingReference) { + throw remainingReference.buildCodeFrameError( + `[react-native-tailwind] ${specifier.imported.name} must be called directly so it can be replaced at compile time.`, + ); + } + + return false; + } + return true; + }); + + if (remainingSpecifiers.length === 0) { + importPath.remove(); + } else if (remainingSpecifiers.length < importPath.node.specifiers.length) { + importPath.node.specifiers = remainingSpecifiers; + } + }, + }); +} diff --git a/src/index.ts b/src/index.ts index 0bd46f6..7720d06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ // Compile-time tw/twStyle functions (transformed by Babel plugin) export { tw, twStyle } from "./stubs/tw"; +export { useTwColor, useTwColors } from "./stubs/twColor"; // Main parser functions export { parseClass, parseClassName } from "./parser"; diff --git a/src/stubs/twColor.test.ts b/src/stubs/twColor.test.ts new file mode 100644 index 0000000..bc18ef0 --- /dev/null +++ b/src/stubs/twColor.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { useTwColor, useTwColors } from "./twColor"; + +describe("raw color stubs", () => { + it("should throw when useTwColor is not transformed", () => { + expect(() => useTwColor("blue-500")).toThrow( + "useTwColor/useTwColors must be transformed by the Babel plugin", + ); + }); + + it("should throw when useTwColors is not transformed", () => { + expect(() => useTwColors({ accent: "blue-500" })).toThrow( + "useTwColor/useTwColors must be transformed by the Babel plugin", + ); + }); +}); diff --git a/src/stubs/twColor.ts b/src/stubs/twColor.ts new file mode 100644 index 0000000..5bb54ca --- /dev/null +++ b/src/stubs/twColor.ts @@ -0,0 +1,20 @@ +/** + * Compile-time raw color helpers. + * + * Calls are replaced by the Babel plugin with literal React Native color + * strings. These stubs throw when the plugin is not configured. + */ + +const transformError = + "useTwColor/useTwColors must be transformed by the Babel plugin. " + + "Ensure @mgcrea/react-native-tailwind/babel is configured in your babel.config.js."; + +/** Resolve one static theme token to a native color string at compile time. */ +export function useTwColor(_token: string): string { + throw new Error(transformError); +} + +/** Resolve a named object of static theme tokens at compile time. */ +export function useTwColors>(_tokens: T): { [K in keyof T]: string } { + throw new Error(transformError); +} From b438cf4dc98d301f5d41ed3862584933543aed20 Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:01:31 -0400 Subject: [PATCH 4/6] feat: add static twColor tag --- docs/src/content/docs/reference/api.md | 21 ++++++-- src/babel/plugin.ts | 15 ++++-- src/babel/plugin/state.ts | 2 +- src/babel/plugin/visitors/imports.ts | 6 ++- src/babel/plugin/visitors/twColor.test.ts | 66 +++++++++++++++++++++++ src/babel/plugin/visitors/twColor.ts | 44 +++++++++++++++ src/babel/utils/twColorProcessing.ts | 4 +- src/index.ts | 2 +- src/stubs/twColor.test.ts | 12 +++-- src/stubs/twColor.ts | 7 ++- 10 files changed, 163 insertions(+), 16 deletions(-) diff --git a/docs/src/content/docs/reference/api.md b/docs/src/content/docs/reference/api.md index d598a17..7e40275 100644 --- a/docs/src/content/docs/reference/api.md +++ b/docs/src/content/docs/reference/api.md @@ -7,7 +7,19 @@ Access the parser and constants programmatically for advanced use cases. ## Compile-Time Raw Colors -Use `useTwColor` when a React Native API needs a color string instead of a `style` object, such as navigation options, gradients, SVG, or Skia: +Use `twColor` for a static color string that can be declared at module scope. It accepts the same palette names, utility-form names, slash opacity, and arbitrary hex values as color utilities: + +```tsx +import { twColor } from "@mgcrea/react-native-tailwind"; + +const blue = twColor`blue-500`; +const translucentBlue = twColor`blue-500/35`; +const customHex = twColor`[#50d71e]`; +``` + +Each tag is replaced with a string literal and has no runtime cost. Interpolations and `scheme:` tokens produce a compile error. A static tag cannot react to a runtime color scheme, so use `useTwColor` for scheme-aware colors. + +Use `useTwColor` when a React Native API needs a reactive color string instead of a `style` object, such as navigation options, gradients, SVG, or Skia: ```tsx import { useTwColor, useTwColors } from "@mgcrea/react-native-tailwind"; @@ -19,12 +31,11 @@ function Header() { } ``` -Raw tokens use the same configured palette as color classes. Prefix a token with `scheme:` to resolve its configured light and dark variants reactively: +Hook tokens use the same configured palette as color classes. Prefix a token with `scheme:` to resolve its configured light and dark variants reactively: ```tsx const card = useTwColor("scheme:card"); -const translucentBlue = useTwColor("blue-500/35"); -const customHex = useTwColor("[#50d71e]"); +const fallback = useTwColor("blue-500"); ``` The Babel plugin replaces each call with literal color strings. Scheme tokens reuse the plugin's configured `colorScheme` hook, including custom application theme hooks. @@ -211,7 +222,7 @@ function ThemedComponent() { ## Important Notes -- `useTwColor` and `useTwColors` are compile-time APIs; `parseClassName` parses styles at runtime +- `twColor`, `useTwColor`, and `useTwColors` are compile-time APIs; `parseClassName` parses styles at runtime - For production apps, prefer using `className` prop for compile-time optimization - Use the programmatic API for: - Testing diff --git a/src/babel/plugin.ts b/src/babel/plugin.ts index 5c5b7da..046d098 100644 --- a/src/babel/plugin.ts +++ b/src/babel/plugin.ts @@ -13,7 +13,7 @@ import { jsxAttributeVisitor } from "./plugin/visitors/className.js"; import { importDeclarationVisitor } from "./plugin/visitors/imports.js"; import { programEnter, programExit } from "./plugin/visitors/program.js"; import { callExpressionVisitor, taggedTemplateVisitor } from "./plugin/visitors/tw.js"; -import { twColorCallExpressionVisitor } from "./plugin/visitors/twColor.js"; +import { twColorCallExpressionVisitor, twColorTaggedTemplateVisitor } from "./plugin/visitors/twColor.js"; import { scanForColorSchemeModifiers } from "./utils/preInjection.js"; import { injectColorSchemeHook } from "./utils/styleInjection.js"; @@ -81,7 +81,11 @@ export default function reactNativeTailwindBabelPlugin( const importedName = spec.imported.name; if (importedName === "tw" || importedName === "twStyle") { state.twImportNames.add(spec.local.name); - } else if (importedName === "useTwColor" || importedName === "useTwColors") { + } else if ( + importedName === "twColor" || + importedName === "useTwColor" || + importedName === "useTwColors" + ) { state.twColorImportNames.set(spec.local.name, importedName); } } @@ -103,7 +107,11 @@ export default function reactNativeTailwindBabelPlugin( state.attributePatterns, state.twImportNames, t, - new Set(state.twColorImportNames.keys()), + new Set( + [...state.twColorImportNames] + .filter(([, helper]) => helper !== "twColor") + .map(([localName]) => localName), + ), ) ) { injectColorSchemeHook( @@ -134,6 +142,7 @@ export default function reactNativeTailwindBabelPlugin( TaggedTemplateExpression(path, state) { taggedTemplateVisitor(path, state, t); + twColorTaggedTemplateVisitor(path, state, t); }, CallExpression(path, state) { diff --git a/src/babel/plugin/state.ts b/src/babel/plugin/state.ts index 946fff1..d7d15d5 100644 --- a/src/babel/plugin/state.ts +++ b/src/babel/plugin/state.ts @@ -116,7 +116,7 @@ export type PluginState = PluginPass & { // Track tw/twStyle imports from main package twImportNames: Set; // e.g., ['tw', 'twStyle'] or ['tw as customTw'] hasTwImport: boolean; - twColorImportNames: Map; + twColorImportNames: Map; hasTwColorImport: boolean; // Track react-native import path for conditional StyleSheet/Platform injection reactNativeImportPath?: NodePath; diff --git a/src/babel/plugin/visitors/imports.ts b/src/babel/plugin/visitors/imports.ts index d1118ab..0c595e0 100644 --- a/src/babel/plugin/visitors/imports.ts +++ b/src/babel/plugin/visitors/imports.ts @@ -110,7 +110,11 @@ export function importDeclarationVisitor( const localName = spec.local.name; state.twImportNames.add(localName); // Don't set hasTwImport yet - only set it when we successfully transform a call - } else if (importedName === "useTwColor" || importedName === "useTwColors") { + } else if ( + importedName === "twColor" || + importedName === "useTwColor" || + importedName === "useTwColors" + ) { state.twColorImportNames.set(spec.local.name, importedName); } } diff --git a/src/babel/plugin/visitors/twColor.test.ts b/src/babel/plugin/visitors/twColor.test.ts index ed1ea8f..8c8b0f2 100644 --- a/src/babel/plugin/visitors/twColor.test.ts +++ b/src/babel/plugin/visitors/twColor.test.ts @@ -3,6 +3,72 @@ import { describe, expect, it } from "vitest"; import { transform } from "../../../../test/helpers/babelTransform.js"; import { COLORS } from "../../../parser/colors.js"; +describe("twColor tag", () => { + it("should compile a module-level static token to a string literal", () => { + const output = transform(` + import { twColor } from '@mgcrea/react-native-tailwind'; + export const color = twColor\`blue-500\`; + `); + + expect(output).toContain(`export const color = "${COLORS["blue-500"]}"`); + expect(output).not.toContain("twColor"); + expect(output).not.toContain("useColorScheme"); + }); + + it("should support utility-form tokens, opacity, arbitrary hex, and aliased imports", () => { + const output = transform(` + import { twColor as color } from '@mgcrea/react-native-tailwind'; + export const background = color\`bg-red-500/35\`; + export const foreground = color\`[#abcdef]\`; + `); + + expect(output).toContain('export const background = "#FB2C3659"'); + expect(output).toContain('export const foreground = "#abcdef"'); + expect(output).not.toContain("twColor"); + expect(output).not.toContain("color`"); + }); + + it("should reject scheme tokens with an actionable useTwColor error", () => { + expect(() => + transform(` + import { twColor } from '@mgcrea/react-native-tailwind'; + export const color = twColor\`scheme:not-configured\`; + `), + ).toThrow(/cannot resolve a runtime color scheme.*Use useTwColor/); + }); + + it("should reject interpolations, empty tags, unknown colors, and function-call syntax", () => { + expect(() => + transform(` + import { twColor } from '@mgcrea/react-native-tailwind'; + const shade = 500; + export const color = twColor\`blue-\${shade}\`; + `), + ).toThrow(/without interpolations/); + + expect(() => + transform(` + import { twColor } from '@mgcrea/react-native-tailwind'; + export const color = twColor\`\`; + `), + ).toThrow(/requires one static color token/); + + expect(() => + transform(` + import { twColor } from '@mgcrea/react-native-tailwind'; + export const color = twColor\`not-a-real-color\`; + `), + ).toThrow(/Unknown or unsupported color token/); + + expect(() => + transform(` + import { twColor } from '@mgcrea/react-native-tailwind'; + export const color = twColor('blue-500'); + `), + ).toThrow(/must be used as a tagged template/); + }); +}); + describe("raw color hooks", () => { it("should compile a static color token to a string literal", () => { const output = transform(` diff --git a/src/babel/plugin/visitors/twColor.ts b/src/babel/plugin/visitors/twColor.ts index 1083a00..74723df 100644 --- a/src/babel/plugin/visitors/twColor.ts +++ b/src/babel/plugin/visitors/twColor.ts @@ -16,6 +16,46 @@ function invalidCall(path: NodePath, message: string) throw path.buildCodeFrameError(`[react-native-tailwind] ${message}`); } +function invalidTag(path: NodePath, message: string): never { + throw path.buildCodeFrameError(`[react-native-tailwind] ${message}`); +} + +/** Compile static twColor`...` tokens to native color string literals. */ +export function twColorTaggedTemplateVisitor( + path: NodePath, + state: PluginState, + t: typeof BabelTypes, +): void { + if (!t.isIdentifier(path.node.tag) || state.twColorImportNames.get(path.node.tag.name) !== "twColor") { + return; + } + + if (path.node.quasi.expressions.length > 0) { + invalidTag(path, "twColor`...` only supports one static color token without interpolations."); + } + + const tokenValue = path.node.quasi.quasis[0]?.value.cooked?.trim() ?? ""; + if (!tokenValue) { + invalidTag(path, "twColor`...` requires one static color token."); + } + + if (tokenValue.startsWith("scheme:")) { + invalidTag( + path, + `twColor\`${tokenValue}\` cannot resolve a runtime color scheme. ` + + `Use useTwColor("${tokenValue}") inside a function component.`, + ); + } + + const token = resolveTwColorToken(tokenValue, state); + if (!token || token.kind !== "static") { + invalidTag(path, `Unknown or unsupported color token: "${tokenValue}".`); + } + + path.replaceWith(t.stringLiteral(token.color)); + state.hasTwColorImport = true; +} + export function twColorCallExpressionVisitor( path: NodePath, state: PluginState, @@ -30,6 +70,10 @@ export function twColorCallExpressionVisitor( return; } + if (helper === "twColor") { + invalidCall(path, "twColor must be used as a tagged template: twColor`blue-500`."); + } + if (path.node.arguments.length !== 1) { invalidCall(path, `${helper}() expects exactly one argument.`); } diff --git a/src/babel/utils/twColorProcessing.ts b/src/babel/utils/twColorProcessing.ts index 47ad599..fa5d48d 100644 --- a/src/babel/utils/twColorProcessing.ts +++ b/src/babel/utils/twColorProcessing.ts @@ -133,7 +133,9 @@ export function removeTwColorImports(path: NodePath, t: type const remainingSpecifiers = importPath.node.specifiers.filter((specifier) => { if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported)) { const isTwColorHelper = - specifier.imported.name === "useTwColor" || specifier.imported.name === "useTwColors"; + specifier.imported.name === "twColor" || + specifier.imported.name === "useTwColor" || + specifier.imported.name === "useTwColors"; if (!isTwColorHelper) { return true; } diff --git a/src/index.ts b/src/index.ts index 7720d06..b26ee91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ // Compile-time tw/twStyle functions (transformed by Babel plugin) export { tw, twStyle } from "./stubs/tw"; -export { useTwColor, useTwColors } from "./stubs/twColor"; +export { twColor, useTwColor, useTwColors } from "./stubs/twColor"; // Main parser functions export { parseClass, parseClassName } from "./parser"; diff --git a/src/stubs/twColor.test.ts b/src/stubs/twColor.test.ts index bc18ef0..8d8376c 100644 --- a/src/stubs/twColor.test.ts +++ b/src/stubs/twColor.test.ts @@ -1,17 +1,23 @@ import { describe, expect, it } from "vitest"; -import { useTwColor, useTwColors } from "./twColor"; +import { twColor, useTwColor, useTwColors } from "./twColor"; describe("raw color stubs", () => { + it("should throw when twColor is not transformed", () => { + expect(() => twColor`blue-500`).toThrow( + "twColor/useTwColor/useTwColors must be transformed by the Babel plugin", + ); + }); + it("should throw when useTwColor is not transformed", () => { expect(() => useTwColor("blue-500")).toThrow( - "useTwColor/useTwColors must be transformed by the Babel plugin", + "twColor/useTwColor/useTwColors must be transformed by the Babel plugin", ); }); it("should throw when useTwColors is not transformed", () => { expect(() => useTwColors({ accent: "blue-500" })).toThrow( - "useTwColor/useTwColors must be transformed by the Babel plugin", + "twColor/useTwColor/useTwColors must be transformed by the Babel plugin", ); }); }); diff --git a/src/stubs/twColor.ts b/src/stubs/twColor.ts index 5bb54ca..734a631 100644 --- a/src/stubs/twColor.ts +++ b/src/stubs/twColor.ts @@ -6,10 +6,15 @@ */ const transformError = - "useTwColor/useTwColors must be transformed by the Babel plugin. " + + "twColor/useTwColor/useTwColors must be transformed by the Babel plugin. " + "Ensure @mgcrea/react-native-tailwind/babel is configured in your babel.config.js."; /** Resolve one static theme token to a native color string at compile time. */ +export function twColor(_strings: TemplateStringsArray, ..._values: unknown[]): string { + throw new Error(transformError); +} + +/** Resolve one reactive theme token to a native color string at compile time. */ export function useTwColor(_token: string): string { throw new Error(transformError); } From 6ba04fb458fd688eae234dc3ee37c361b1e5aec4 Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:47:50 -0400 Subject: [PATCH 5/6] feat: preserve typed raw color literals --- docs/src/content/docs/reference/api.md | 20 ++++++++++ src/babel/plugin/visitors/twColor.test.ts | 47 +++++++++++++++++++++++ src/babel/plugin/visitors/twColor.ts | 29 +++++++++++--- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/reference/api.md b/docs/src/content/docs/reference/api.md index 7e40275..9be3258 100644 --- a/docs/src/content/docs/reference/api.md +++ b/docs/src/content/docs/reference/api.md @@ -61,6 +61,26 @@ function Screen() { Both helpers require static string literals and must be called unconditionally inside a function component. Unknown colors, unsupported modifiers, dynamic expressions, and module-scope calls produce a compile error. Utility-form tokens such as `bg-card` and `text-accent` are accepted, but raw palette names are preferred when the consumer needs only a string. +### Type Checking Custom Theme Tokens + +The Babel plugin validates every token against the actual Tailwind configuration, but the exported TypeScript signature accepts `string` because a library declaration cannot automatically infer another project's Babel-loaded configuration or arbitrary-value grammar. Applications can keep config-derived autocomplete and typo checking with a direct `satisfies` expression: + +```tsx +import tailwindConfig from "./tailwind.config"; + +type ThemeColor = keyof typeof tailwindConfig.theme.extend.colors; +type SchemeThemeColor = `scheme:${ThemeColor}`; + +function Card() { + const background = useTwColor("scheme:card" satisfies SchemeThemeColor); + const colors = useTwColors({ + text: "scheme:text" satisfies SchemeThemeColor, + }); +} +``` + +TypeScript checks the literal against the application's config-derived union, then the Babel plugin removes the type-only expression and compiles the color normally. Keep the literal directly inside `useTwColor` or `useTwColors`; variables and wrapper functions are intentionally rejected so the compiler can prove the token statically. + ## parseClassName Parse className strings to React Native styles: diff --git a/src/babel/plugin/visitors/twColor.test.ts b/src/babel/plugin/visitors/twColor.test.ts index 8c8b0f2..6055fac 100644 --- a/src/babel/plugin/visitors/twColor.test.ts +++ b/src/babel/plugin/visitors/twColor.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { transform } from "../../../../test/helpers/babelTransform.js"; import { COLORS } from "../../../parser/colors.js"; +import { applyOpacity } from "../../../utils/colorUtils.js"; describe("twColor tag", () => { it("should compile a module-level static token to a string literal", () => { @@ -28,6 +29,15 @@ describe("twColor tag", () => { expect(output).not.toContain("color`"); }); + it("should support arbitrary opacity from the shared color grammar", () => { + const output = transform(` + import { twColor } from '@mgcrea/react-native-tailwind'; + export const color = twColor\`blue-500/[.37]\`; + `); + + expect(output).toContain('export const color = "#2B7FFF5E"'); + }); + it("should reject scheme tokens with an actionable useTwColor error", () => { expect(() => transform(` @@ -127,6 +137,43 @@ describe("raw color hooks", () => { expect(output).toContain(`: "${COLORS["gray-100"]}"`); }); + it("should compile scheme outline colors with arbitrary opacity", () => { + const output = transform( + ` + import { useTwColor } from '@mgcrea/react-native-tailwind'; + export function Component() { + return useTwColor('scheme:outline-gray/[.37]'); + } + `, + { schemeModifier: { darkSuffix: "-900", lightSuffix: "-100" } }, + ); + + expect(output).toContain(`? "${applyOpacity(COLORS["gray-900"], 37)}"`); + expect(output).toContain(`: "${applyOpacity(COLORS["gray-100"], 37)}"`); + }); + + it("should preserve TypeScript satisfies checks around direct static tokens", () => { + const output = transform( + ` + import { useTwColor, useTwColors } from '@mgcrea/react-native-tailwind'; + type AppColor = 'card' | 'text'; + type AppSchemeColor = \`scheme:\${AppColor}\`; + + export function Component() { + const card = useTwColor('scheme:gray' satisfies string); + const colors = useTwColors({ text: 'black' satisfies string }); + return [card, colors.text]; + } + `, + { schemeModifier: { darkSuffix: "-900", lightSuffix: "-100" } }, + true, + ); + + expect(output).toContain(`? "${COLORS["gray-900"]}"`); + expect(output).toContain('text: "#000000"'); + expect(output).not.toContain("satisfies"); + }); + it("should inject one scheme hook for a color object", () => { const output = transform( ` diff --git a/src/babel/plugin/visitors/twColor.ts b/src/babel/plugin/visitors/twColor.ts index 74723df..2e95eb2 100644 --- a/src/babel/plugin/visitors/twColor.ts +++ b/src/babel/plugin/visitors/twColor.ts @@ -20,6 +20,18 @@ function invalidTag(path: NodePath, message throw path.buildCodeFrameError(`[react-native-tailwind] ${message}`); } +function getStaticStringLiteral(node: BabelTypes.Node, t: typeof BabelTypes): BabelTypes.StringLiteral | null { + if (t.isStringLiteral(node)) { + return node; + } + + if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isTSTypeAssertion(node)) { + return getStaticStringLiteral(node.expression, t); + } + + return null; +} + /** Compile static twColor`...` tokens to native color string literals. */ export function twColorTaggedTemplateVisitor( path: NodePath, @@ -84,13 +96,14 @@ export function twColorCallExpressionVisitor( let needsScheme = false; if (helper === "useTwColor") { - if (!t.isStringLiteral(argument)) { + const stringArgument = getStaticStringLiteral(argument, t); + if (!stringArgument) { invalidCall(path, "useTwColor() requires a static string literal."); } - const token = resolveTwColorToken(argument.value, state); + const token = resolveTwColorToken(stringArgument.value, state); if (!token) { - invalidCall(path, `Unknown or unsupported color token: "${argument.value}".`); + invalidCall(path, `Unknown or unsupported color token: "${stringArgument.value}".`); } needsScheme = token.kind === "scheme"; @@ -111,14 +124,18 @@ export function twColorCallExpressionVisitor( !t.isObjectProperty(property) || property.computed || (!t.isIdentifier(property.key) && !t.isStringLiteral(property.key)) || - !t.isStringLiteral(property.value) + !getStaticStringLiteral(property.value, t) ) { invalidCall(path, "useTwColors() only supports plain object properties with static string values."); } - const token = resolveTwColorToken(property.value.value, state); + const stringValue = getStaticStringLiteral(property.value, t); + if (!stringValue) { + invalidCall(path, "useTwColors() only supports plain object properties with static string values."); + } + const token = resolveTwColorToken(stringValue.value, state); if (!token) { - invalidCall(path, `Unknown or unsupported color token: "${property.value.value}".`); + invalidCall(path, `Unknown or unsupported color token: "${stringValue.value}".`); } needsScheme ||= token.kind === "scheme"; tokens.push({ property, token }); From 934f2cf9f7c9838e7334550824a6d1112f54a1e8 Mon Sep 17 00:00:00 2001 From: Evan Simpson <25159851+e-simpson@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:00:17 -0400 Subject: [PATCH 6/6] fix: pre-inject typed scheme color hooks --- src/babel/utils/preInjection.test.ts | 57 ++++++++++++++++++++++++++++ src/babel/utils/preInjection.ts | 19 +++++++--- 2 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 src/babel/utils/preInjection.test.ts diff --git a/src/babel/utils/preInjection.test.ts b/src/babel/utils/preInjection.test.ts new file mode 100644 index 0000000..2169f73 --- /dev/null +++ b/src/babel/utils/preInjection.test.ts @@ -0,0 +1,57 @@ +import { parseSync } from "@babel/core"; +import * as BabelTypes from "@babel/types"; +import { describe, expect, it } from "vitest"; + +import { scanForColorSchemeModifiers } from "./preInjection"; + +describe("scanForColorSchemeModifiers", () => { + it("should detect typed scheme tokens before React Compiler analysis", () => { + const ast = parseSync( + ` + function Component() { + return useTwColor("scheme:card" satisfies AppThemeColor); + } + `, + { parserOpts: { plugins: ["typescript"] } }, + ); + const declaration = ast?.program.body[0]; + expect(BabelTypes.isFunctionDeclaration(declaration)).toBe(true); + if (!BabelTypes.isFunctionDeclaration(declaration)) return; + + expect( + scanForColorSchemeModifiers( + declaration.body, + new Set(), + [], + new Set(), + BabelTypes, + new Set(["useTwColor"]), + ), + ).toBe(true); + }); + + it("should detect typed scheme values in useTwColors objects", () => { + const ast = parseSync( + ` + function Component() { + return useTwColors({ card: "scheme:card" as AppThemeColor }); + } + `, + { parserOpts: { plugins: ["typescript"] } }, + ); + const declaration = ast?.program.body[0]; + expect(BabelTypes.isFunctionDeclaration(declaration)).toBe(true); + if (!BabelTypes.isFunctionDeclaration(declaration)) return; + + expect( + scanForColorSchemeModifiers( + declaration.body, + new Set(), + [], + new Set(), + BabelTypes, + new Set(["useTwColors"]), + ), + ).toBe(true); + }); +}); diff --git a/src/babel/utils/preInjection.ts b/src/babel/utils/preInjection.ts index 84384f9..e6ef79f 100644 --- a/src/babel/utils/preInjection.ts +++ b/src/babel/utils/preInjection.ts @@ -14,6 +14,15 @@ import type * as BabelTypes from "@babel/types"; const COLOR_SCHEME_PATTERN = /(?:^|\s)(?:dark:|light:|scheme:)/; +function getStaticStringValue(node: BabelTypes.Node | undefined, t: typeof BabelTypes): string | null { + if (!node) return null; + if (t.isStringLiteral(node)) return node.value; + if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isTSTypeAssertion(node)) { + return getStaticStringValue(node.expression, t); + } + return null; +} + /** * Scan an AST node tree for color scheme modifiers in class name contexts. * @@ -70,16 +79,14 @@ function walkNode( if (twColorImportNames.has(node.callee.name)) { const arg = node.arguments[0]; - if (t.isStringLiteral(arg) && COLOR_SCHEME_PATTERN.test(arg.value)) { + const token = arg && !t.isArgumentPlaceholder(arg) ? getStaticStringValue(arg, t) : null; + if (token && COLOR_SCHEME_PATTERN.test(token)) { return true; } if (t.isObjectExpression(arg)) { for (const property of arg.properties) { - if ( - t.isObjectProperty(property) && - t.isStringLiteral(property.value) && - COLOR_SCHEME_PATTERN.test(property.value.value) - ) { + const propertyToken = t.isObjectProperty(property) ? getStaticStringValue(property.value, t) : null; + if (propertyToken && COLOR_SCHEME_PATTERN.test(propertyToken)) { return true; } }