@@ -8,6 +8,8 @@ import { cssDeclarationToStylexDeclarations } from "../css-prop-mapping.js";
88import { cssValueToJs , normalizeCssContentValue } from "../transform/helpers.js" ;
99import { cssKeyframeNameToIdentifier , expandStaticAnimationShorthand } from "../keyframes.js" ;
1010import { handleInterpolatedDeclaration } from "./rule-interpolated-declaration.js" ;
11+ import { PLACEHOLDER_RE } from "../styled-css.js" ;
12+ import { isIdentifierNode , literalToStaticValue } from "../utilities/jscodeshift-utils.js" ;
1113
1214type CommentSource = { leading ?: string ; trailingLine ?: string } | null ;
1315
@@ -37,12 +39,19 @@ export function processRuleDeclarations(args: RuleDeclarationContext): void {
3739
3840 for ( const d of rule . declarations ) {
3941 // Dynamic property names (slot placeholders in property position) such as
40- // `${CSS_VAR}: 100%;` cannot be safely lowered to StyleX. Bail with a clear
41- // warning instead of emitting a broken style entry whose key is the raw
42- // placeholder text (e.g. `__SC_EXPR_0__`).
42+ // `${CSS_VAR}: 100%;`. Try to resolve every placeholder in the property
43+ // name to a static string (e.g. via a top-level `const X = "--var"`). If
44+ // every slot resolves to a CSS-variable-compatible literal, substitute the
45+ // resolved name and continue processing as a regular declaration. Bail
46+ // otherwise — emitting the raw `__SC_EXPR_N__` placeholder produces broken
47+ // StyleX output.
4348 if ( d . property && d . property . includes ( "__SC_EXPR_" ) ) {
44- ctx . state . bailUnsupported ( ctx . decl , "Unsupported interpolation: property" ) ;
45- break ;
49+ const resolvedProperty = resolveInterpolatedPropertyName ( d . property , ctx ) ;
50+ if ( resolvedProperty === null ) {
51+ ctx . state . bailUnsupported ( ctx . decl , "Unsupported interpolation: property" ) ;
52+ break ;
53+ }
54+ d . property = resolvedProperty ;
4655 }
4756
4857 if ( d . value . kind === "interpolated" ) {
@@ -126,3 +135,104 @@ export function processRuleDeclarations(args: RuleDeclarationContext): void {
126135 }
127136 }
128137}
138+
139+ // --- Non-exported helpers ---
140+
141+ /**
142+ * Attempts to substitute `__SC_EXPR_N__` placeholders in a CSS property name
143+ * with statically-resolvable string values pulled from the styled component's
144+ * template expressions. Only succeeds when:
145+ * - every placeholder slot resolves to a string literal (directly or via a
146+ * top-level `const NAME = "..."` binding in the same file), and
147+ * - the resulting property name is a CSS custom property (starts with `--`).
148+ *
149+ * Returns the resolved property name on success, or `null` when the property
150+ * cannot be safely lowered.
151+ */
152+ function resolveInterpolatedPropertyName (
153+ property : string ,
154+ ctx : DeclProcessingState ,
155+ ) : string | null {
156+ const { decl, state } = ctx ;
157+ const placeholderRe = new RegExp ( PLACEHOLDER_RE . source , "g" ) ;
158+ let failed = false ;
159+ const resolved = property . replace ( placeholderRe , ( _match , slotIdRaw : string ) => {
160+ const slotId = Number ( slotIdRaw ) ;
161+ const expr = decl . templateExpressions [ slotId ] ;
162+ const value = resolveExpressionToStaticString ( expr , state ) ;
163+ if ( value === null ) {
164+ failed = true ;
165+ return "" ;
166+ }
167+ return value ;
168+ } ) ;
169+ if ( failed ) {
170+ return null ;
171+ }
172+ // Only substitute names that look like CSS custom properties to avoid
173+ // accidentally turning unrelated dynamic patterns (e.g. computed standard
174+ // property names) into silently mistransformed output.
175+ if ( ! resolved . startsWith ( "--" ) ) {
176+ return null ;
177+ }
178+ return resolved ;
179+ }
180+
181+ /**
182+ * Resolves an AST expression to a static string. Handles direct string literals
183+ * and identifiers bound to top-level `const NAME = "..."` declarations in the
184+ * file being transformed.
185+ */
186+ function resolveExpressionToStaticString (
187+ expr : unknown ,
188+ state : DeclProcessingState [ "state" ] ,
189+ ) : string | null {
190+ const direct = literalToStaticValue ( expr ) ;
191+ if ( typeof direct === "string" ) {
192+ return direct ;
193+ }
194+ if ( isIdentifierNode ( expr ) ) {
195+ return findTopLevelConstStringInit ( expr . name , state ) ;
196+ }
197+ return null ;
198+ }
199+
200+ /**
201+ * Finds a top-level `const <name> = <literal>` declaration in the current file
202+ * and returns its initializer when it resolves to a static string. Skips
203+ * declarators with multiple bindings or non-`const` declarations to avoid
204+ * picking up reassignable values.
205+ */
206+ function findTopLevelConstStringInit (
207+ name : string ,
208+ state : DeclProcessingState [ "state" ] ,
209+ ) : string | null {
210+ const { root, j } = state ;
211+ let resolved : string | null = null ;
212+ root
213+ . find ( j . VariableDeclaration , { kind : "const" } as { kind : "const" } )
214+ . filter ( ( p ) => {
215+ const parentType = ( p . parent ?. node as { type ?: string } | undefined ) ?. type ;
216+ return parentType === "Program" || parentType === "ExportNamedDeclaration" ;
217+ } )
218+ . forEach ( ( p ) => {
219+ if ( resolved !== null ) {
220+ return ;
221+ }
222+ for ( const declarator of p . node . declarations ) {
223+ if (
224+ declarator . type !== "VariableDeclarator" ||
225+ declarator . id . type !== "Identifier" ||
226+ declarator . id . name !== name
227+ ) {
228+ continue ;
229+ }
230+ const value = literalToStaticValue ( declarator . init ) ;
231+ if ( typeof value === "string" ) {
232+ resolved = value ;
233+ }
234+ return ;
235+ }
236+ } ) ;
237+ return resolved ;
238+ }
0 commit comments