diff --git a/package.json b/package.json index d4f8825..8f988aa 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "dependencies": { "css": "^3.0.0", "css-mediaquery": "^0.1.2", - "@cssxjs/css-to-react-native": "^3.2.0-0" + "@cssxjs/css-to-react-native": "3.2.0-1" }, "repository": { "type": "git", diff --git a/src/index.js b/src/index.js index 9fe24d7..1fba06b 100644 --- a/src/index.js +++ b/src/index.js @@ -25,7 +25,54 @@ const shorthandBorderProps = [ "border-style", ]; -const transformDecls = (styles, declarations, result) => { +/** + * Extracts @keyframes from CSS and returns the cleaned CSS and keyframes object + * @param {string} css - The input CSS string + * @returns {{css: string, keyframes: Object}} - Cleaned CSS and keyframes object + */ +const extractKeyframes = (css) => { + const keyframes = {}; + let cleanedCss = css; + + // Find @keyframes by manually parsing to handle nested braces + let index = 0; + while (index < css.length) { + const keyframesMatch = css.slice(index).match(/@keyframes\s+([^\s{]+)\s*{/); + if (!keyframesMatch) break; + + const startIndex = index + keyframesMatch.index; + const nameEndIndex = startIndex + keyframesMatch[0].length; + const name = keyframesMatch[1]; + + // Find the matching closing brace + let braceCount = 1; + let currentIndex = nameEndIndex; + while (currentIndex < css.length && braceCount > 0) { + if (css[currentIndex] === '{') { + braceCount++; + } else if (css[currentIndex] === '}') { + braceCount--; + } + currentIndex++; + } + + if (braceCount === 0) { + // Extract the body (without the outer braces) + const body = css.slice(nameEndIndex, currentIndex - 1).trim(); + keyframes[name] = body; + + // Mark for removal by replacing with spaces to maintain positions + const fullKeyframe = css.slice(startIndex, currentIndex); + cleanedCss = cleanedCss.replace(fullKeyframe, ''); + } + + index = currentIndex; + } + + return { css: cleanedCss, keyframes }; +}; + +const transformDecls = (styles, declarations, result, keyframes) => { for (const d in declarations) { const declaration = declarations[d]; if (declaration.type !== "declaration") continue; @@ -64,6 +111,17 @@ const transformDecls = (styles, declarations, result) => { } else { Object.assign(styles, transformed); } + } else if (['animation', 'animation-name'].includes(property) && keyframes && Object.keys(keyframes).length > 0) { + // Pass all keyframes to transformCSS - it will figure out which ones to use + const keyframeDeclarations = []; + for (const name in keyframes) { + keyframeDeclarations.push(['@keyframes ' + name, keyframes[name]]); + } + const transformed = transformCSS([ + ...keyframeDeclarations, + [property, value] + ]); + Object.assign(styles, transformed); } else { Object.assign(styles, transformCSS([[property, value]])); } @@ -71,6 +129,12 @@ const transformDecls = (styles, declarations, result) => { }; const transform = (css, options) => { + // Extract keyframes and store them separately, before parsing (remove them from css) + let keyframes; + if (options?.parseKeyframes) { + ({ css, keyframes } = extractKeyframes(css)); + } + const { stylesheet } = parseCSS(css); const rules = sortRules(stylesheet.rules); @@ -126,7 +190,7 @@ const transform = (css, options) => { const selector = rule.selectors[s].replace(/^\./, ""); const styles = (result[selector] = result[selector] || {}); - transformDecls(styles, rule.declarations, result); + transformDecls(styles, rule.declarations, result, keyframes); } if ( @@ -179,14 +243,18 @@ const transform = (css, options) => { const selector = ruleRule.selectors[s].replace(/^\./, ""); const mediaStyles = (result[media][selector] = result[media][selector] || {}); - transformDecls(mediaStyles, ruleRule.declarations, result); + transformDecls(mediaStyles, ruleRule.declarations, result, keyframes); } } } } if (result.__exportProps) { - Object.assign(result, result.__exportProps); + if (Object.keys(result.__exportProps).length === 0) { + delete result.__exportProps; + } else { + Object.assign(result, result.__exportProps); + } } return result; diff --git a/src/index.spec.js b/src/index.spec.js index 63f8ec8..03bc572 100644 --- a/src/index.spec.js +++ b/src/index.spec.js @@ -268,10 +268,7 @@ describe("misc", () => { fontSize: 18, textAlign: "center", color: "#656656", - shadowColor: "#fff", - shadowOffset: { height: 20, width: 10 }, - shadowRadius: 30, - shadowOpacity: 1, + boxShadow: "10px 20px 30px #fff", }, container: { paddingBottom: 30, @@ -2210,7 +2207,7 @@ describe("font", () => { }); describe("box-shadow", () => { - it("transforms box-shadow into shadow- properties", () => { + it("transforms box-shadow", () => { expect( transform(` .test { @@ -2219,10 +2216,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 10, height: 20 }, - shadowRadius: 30, - shadowColor: "red", - shadowOpacity: 1, + boxShadow: "10px 20px 30px red", }, }); expect( @@ -2233,10 +2227,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 10, height: 20 }, - shadowRadius: 30, - shadowColor: "#f00", - shadowOpacity: 1, + boxShadow: "10px 20px 30px #f00", }, }); }); @@ -2250,10 +2241,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 10, height: 20 }, - shadowRadius: 30, - shadowColor: "rgb(100, 100, 100)", - shadowOpacity: 1, + boxShadow: "10px 20px 30px rgb(100, 100, 100)", }, }); }); @@ -2267,10 +2255,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 10, height: 20 }, - shadowRadius: 30, - shadowColor: "rgba(100, 100, 100, 0.5)", - shadowOpacity: 1, + boxShadow: "10px 20px 30px rgba(100, 100, 100, 0.5)", }, }); }); @@ -2284,10 +2269,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 10, height: 20 }, - shadowRadius: 30, - shadowColor: "hsl(120, 100%, 50%)", - shadowOpacity: 1, + boxShadow: "10px 20px 30px hsl(120, 100%, 50%)", }, }); }); @@ -2301,15 +2283,12 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 10, height: 20 }, - shadowRadius: 30, - shadowColor: "hsla(120, 100%, 50%, 0.7)", - shadowOpacity: 1, + boxShadow: "10px 20px 30px hsla(120, 100%, 50%, 0.7)", }, }); }); - it("trims values", () => { + it("preserves spacing in values", () => { expect( transform(` .test { @@ -2318,10 +2297,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 10, height: 20 }, - shadowRadius: 30, - shadowColor: "#f00", - shadowOpacity: 1, + boxShadow: "10px 20px 30px #f00", }, }); }); @@ -2335,10 +2311,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 0, height: 0 }, - shadowRadius: 1, - shadowColor: "red", - shadowOpacity: 1, + boxShadow: "0 0 1px red", }, }); expect( @@ -2349,10 +2322,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 0, height: 0 }, - shadowRadius: 0, - shadowColor: "red", - shadowOpacity: 1, + boxShadow: "0 0 0 red", }, }); expect( @@ -2363,10 +2333,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 1, height: 1 }, - shadowRadius: 0, - shadowColor: "#00f", - shadowOpacity: 1, + boxShadow: "1px 1px 0 #00f", }, }); }); @@ -2380,10 +2347,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 10, height: 20 }, - shadowRadius: 0, - shadowColor: "red", - shadowOpacity: 1, + boxShadow: "10px 20px red", }, }); }); @@ -2397,10 +2361,7 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 10, height: 20 }, - shadowRadius: 30, - shadowColor: "black", - shadowOpacity: 1, + boxShadow: "10px 20px 30px", }, }); }); @@ -2414,71 +2375,11 @@ describe("box-shadow", () => { `), ).toEqual({ test: { - shadowOffset: { width: 10, height: 20 }, - shadowRadius: 0, - shadowColor: "black", - shadowOpacity: 1, + boxShadow: "10px 20px", }, }); }); - it("transforms box-shadow enforces offset to be present", () => { - expect(() => { - transform(` - .test { - box-shadow: red; - } - `); - }).toThrowError('Failed to parse declaration "boxShadow: red"'); - }); - - it("transforms box-shadow and throws if multiple colors are used", () => { - expect(() => { - transform(` - .test { - box-shadow: 0 0 0 red yellow green blue; - } - `); - }).toThrowError( - 'Failed to parse declaration "boxShadow: 0 0 0 red yellow green blue"', - ); - }); - - it("transforms box-shadow and enforces offset-y if offset-x present", () => { - expect(() => { - transform(` - .test { - box-shadow: 10px; - } - `); - }).toThrowError('Failed to parse declaration "boxShadow: 10px"'); - }); - - it("transforms box-shadow and enforces units for non 0 values", () => { - expect(() => { - transform(` - .test { - box-shadow: 10 20px 30px #f00; - } - `); - }).toThrowError( - 'Failed to parse declaration "boxShadow: 10 20px 30px #f00"', - ); - expect(() => { - transform(` - .test { - box-shadow: 10px 20; - } - `); - }).toThrowError('Failed to parse declaration "boxShadow: 10px 20"'); - expect(() => { - transform(` - .test { - box-shadow: 20; - } - `); - }).toThrowError('Failed to parse declaration "boxShadow: 20"'); - }); }); describe("text-shadow", () => { @@ -2627,10 +2528,7 @@ describe("rem unit", () => { transform: [{ translateY: 32 }, { translateX: 16 }], }, test2: { - shadowColor: "#fff", - shadowOffset: { height: 32, width: 16 }, - shadowRadius: 48, - shadowOpacity: 1, + boxShadow: "16px 32px 48px #fff", }, }); }); @@ -3471,6 +3369,10 @@ describe("ICSS :export pseudo-selector", () => { } `), ).toEqual({ + __exportProps: { + whitecolor: "#fcf5ed", + test: "1px", + }, whitecolor: "#fcf5ed", test: "1px", }); @@ -3497,6 +3399,11 @@ describe("ICSS :export pseudo-selector", () => { bar: { color: "blue", }, + __exportProps: { + blackColor: "#000", + whitecolor: "#fcf5ed", + test: "1px", + }, blackColor: "#000", whitecolor: "#fcf5ed", test: "1px", @@ -3518,6 +3425,9 @@ describe("ICSS :export pseudo-selector", () => { bar: { color: "blue", }, + __exportProps: { + blackColor: "something is something", + }, blackColor: "something is something", }); }); @@ -3538,6 +3448,10 @@ describe("ICSS :export pseudo-selector", () => { bar: { color: "blue", }, + __exportProps: { + foo: "something", + boo: "0", + }, foo: "something", boo: "0", }); @@ -3552,6 +3466,10 @@ describe("ICSS :export pseudo-selector", () => { } `), ).toEqual({ + __exportProps: { + whitecolor: "#fcf5ed", + WhiteColor: "#fff", + }, whitecolor: "#fcf5ed", WhiteColor: "#fff", }); @@ -3574,6 +3492,11 @@ describe("ICSS :export pseudo-selector", () => { foo: { color: "blue", }, + __exportProps: { + whitecolor: "#fcf5ed", + b: "0", + test: "1px", + }, whitecolor: "#fcf5ed", b: "0", test: "1px", @@ -3613,6 +3536,9 @@ describe("ICSS :export pseudo-selector", () => { foo: { color: "blue", }, + __exportProps: { + bar: "2", + }, bar: "2", }); expect( @@ -3634,6 +3560,9 @@ describe("ICSS :export pseudo-selector", () => { foo: { color: "blue", }, + __exportProps: { + bar: "2", + }, bar: "2", }); expect( @@ -3656,6 +3585,10 @@ describe("ICSS :export pseudo-selector", () => { foo: { color: "blue", }, + __exportProps: { + baz: "1", + bar: "2", + }, baz: "1", bar: "2", }); @@ -3810,3 +3743,215 @@ describe("::part() selectors", () => { }); }); }); + +describe("@keyframes rules", () => { + it("transforms @keyframes rules with animation property", () => { + expect( + transform( + ` + .container { + animation: slidein 3s ease-in-out infinite; + background-color: #f00; + } + + @keyframes slidein { + from { + transform: translateX(0%); + } + to { + transform: translateX(100%); + } + } + `, + { + parseKeyframes: true, + }, + ), + ).toEqual({ + container: { + animationName: { + from: { transform: [{ translateX: "0%" }] }, + to: { transform: [{ translateX: "100%" }] }, + }, + animationDuration: '3s', + animationDelay: '0s', + animationDirection: 'normal', + animationFillMode: 'none', + animationPlayState: 'running', + animationTimingFunction: 'ease-in-out', + animationIterationCount: 'infinite', + backgroundColor: "#f00", + } + }); + }); + + it("transforms animation-name property with @keyframes", () => { + expect( + transform( + ` + .container { + animation-name: fadeIn; + animation-duration: 2s; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + `, + { + parseKeyframes: true, + }, + ), + ).toEqual({ + container: { + animationName: { + from: { opacity: 0 }, + to: { opacity: 1 }, + }, + animationDuration: '2s', + } + }); + }); + + it("transforms animation property without @keyframes", () => { + expect( + transform( + ` + .container { + animation: slidein 3s ease-in-out infinite; + background-color: #f00; + } + `, + { + parseKeyframes: true, + }, + ), + ).toEqual({ + container: { + animationName: 'slidein', + animationDuration: '3s', + animationDelay: '0s', + animationDirection: 'normal', + animationFillMode: 'none', + animationPlayState: 'running', + animationTimingFunction: 'ease-in-out', + animationIterationCount: 'infinite', + backgroundColor: "#f00", + } + }); + }); + + it("transforms animation-name property without @keyframes", () => { + expect( + transform( + ` + .container { + animation-name: fadeIn; + animation-duration: 2s; + } + `, + { + parseKeyframes: true, + }, + ), + ).toEqual({ + container: { + animationName: 'fadeIn', + animationDuration: '2s', + } + }); + }); + + it("transforms animation: none", () => { + expect( + transform( + ` + .container { + animation: none; + } + `, + { + parseKeyframes: true, + }, + ), + ).toEqual({ + container: { + animationName: 'none', + animationDuration: '0s', + animationDelay: '0s', + animationDirection: 'normal', + animationFillMode: 'none', + animationPlayState: 'running', + animationTimingFunction: 'ease', + animationIterationCount: 1, + } + }); + }); + + it("transforms multiple animations with unused keyframe", () => { + expect( + transform( + ` + .container { + animation: slidein 2s, fadeIn 1s ease-in; + } + + @keyframes slidein { + from { + transform: translateX(0%); + } + to { + transform: translateX(100%); + } + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes unused { + from { + color: red; + } + to { + color: blue; + } + } + `, + { + parseKeyframes: true, + }, + ), + ).toEqual({ + container: { + animationName: [ + { + from: { transform: [{ translateX: "0%" }] }, + to: { transform: [{ translateX: "100%" }] }, + }, + { + from: { opacity: 0 }, + to: { opacity: 1 }, + } + ], + animationDuration: ['2s', '1s'], + animationDelay: ['0s', '0s'], + animationDirection: ['normal', 'normal'], + animationFillMode: ['none', 'none'], + animationPlayState: ['running', 'running'], + animationTimingFunction: ['ease', 'ease-in'], + animationIterationCount: [1, 1], + } + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7812b79..c5f44ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -947,10 +947,10 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@cssxjs/css-to-react-native@^3.2.0-0": - version "3.2.0-0" - resolved "https://registry.yarnpkg.com/@cssxjs/css-to-react-native/-/css-to-react-native-3.2.0-0.tgz#67e065b83e903e18f6aa345b612713d46ed084ac" - integrity sha512-0476VpModcMzRp3qTC38IU652bKpj+sfSM/CO3YIy7iJnVoNHuuScieQQCGKCsXJ+92L9LNXR5EoN/0tfLZgJw== +"@cssxjs/css-to-react-native@3.2.0-1": + version "3.2.0-1" + resolved "https://registry.yarnpkg.com/@cssxjs/css-to-react-native/-/css-to-react-native-3.2.0-1.tgz#16ae82a72abd812363662fd768fd5dfe84600f5a" + integrity sha512-G0AnYEQANn2Y7Afqd0V8f6DNvDL70nv5wXuiY97mQOhJPGsshKjZQe+i082uVO1QOUXTZyRWTE0yCY3SiPxDGw== dependencies: camelize "^1.0.0" css-color-keywords "^1.0.0"