Normalizing Less CSS Mixin Arguments For Use In JavaScript Variable Interpolation
As I blogged about before, you can execute JavaScript code in your Less CSS. You can even pass Less CSS variables into the JavaScript context using variable interpolation. The problem with variable interpolation, however, is that it can corrupt the isolation of each argument and/or cause JavaScript syntax errors. As such, I wanted to do some digging to see if I could find a way to normalize Less CSS arguments for use as JavaScript invocation arguments.
Before we get into the way I solved this problem, let's first see what exactly goes wrong with standard variable interpolation. In the following demo, I'm taking the Less CSS mixin argument list and turning it into a JavaScript string. This will give us some idea of how Less is translating the arguments during interpolation:
.test( ... ) {
content: `"@{arguments}"` ;
}
body {
.test( 1 ) ;
.test( ~"1, 2", 3 ) ;
.test( 1, 2 ; 3, 4 ) ;
.test( ~"1, 2, 3" 4 5 ) ;
}
Notice that the first mixin invocation has one argument; the second invocation has two arguments; the third invocation has two arguments; and, the fourth invocation has three arguments. When we compile the Less CSS, we get the following CSS output:
body {
content: "1";
content: "[1, 2, 3]";
content: "[1, 2, 3, 4]";
content: "[1, 2, 3, 4, 5]";
}
There's two visible problems with this output. For starters the data type is inconsistent; when it's less than two arguments, the data type is a simple value; when it's two or more arguments, the data type is an array.
But, that's easy to fix. The more destructive problem is that the interpolated value does not properly translate the number of arguments. While the second and third mixin invocations both had two arguments, they show up as a 3 and 4 item array, respectively. And, the fourth invocation, which has three arguments, shows up as a 5 item array.
Essentially, when the arguments list is interpolated, all of the delimiting characters become actual delimiters, even if they were embedded within a single item.
There are also some hidden problems that I couldn't easily demonstrate. If I don't escape the strings, I get a JavaScript error. By not escaping the strings, I ended up with a JavaScript literal that is twice-quoted; which throws a JavaScript syntax error. And, of course, if any of the string values contains an embedded double-quote, I get an unterminated string literal error.
Basically, passing variables from a Less CSS context into a JavaScript context can be hairy beast. To get around all of these problems, I'm going to try and normalize the argument list before I pass it into JavaScript. To do this, I am going to:
- Make sure that all items in the list are surrounded by double-quotes.
- Make sure that any embedded quotes (single and double) are escaped for use in a JavaScript string.
- Make sure that the list is always in array format (even when less than two arguments).
- Format the list as JSON (JavaScript Object Notation) so that the interpolate value is simple to consume in JavaScript.
To make life easier, I've tried to wrap this entire process up in its own mixin, .stringify-arguments. When you invoke this mixin, it creates a new variable, @stringify-arguments, which contains the serialized JSON value.
// When you pass arguments into a JavaScript context, you must do so through variable
// interpolation. This means that Less CSS has to convert the arguments collection into
// a string; which, depending on how the arguments were created, can lead to an
// ambiguous list or even JavaScript errors. As such, we may need to normalize the
// arguments collection such that each item in the arguments list is double-quoted.
.stringify-arguments( @args ) {
@length: length( @args ) ;
// We need to loop over the list of arguments and normalize each one individually.
// As we do, were going to rebuild the list with each item surrounded by double
// quotes.
.loop( 1 ) ;
// Once the looping has finished, we want to return the arguments list as a JSON
// (JavaScript Object Notation) value so that it can be easily passed into other
// JavaScript contexts.
@stringify-arguments: ~`
(function( args, undefined ) {
if ( args === undefined ) {
return( JSON.stringify( [] ) );
}
return(
JSON.stringify( Array.isArray( args ) ? args : [ args ] )
);
})( @{loop} )
` ;
// --
// PRIVATE MIXINS.
// --
// If there are no arguments in the list, just return the empty list.
.loop( @index ) when ( @length = 0 ) {
@loop: ;
}
// For the FIRST ARGUMENT in the list, we know were starting the new list; as such,
// we wont have to worry about list concatenation.
.loop( @index ) when ( @index <= @length ) and ( @index = 1 ) {
.normalize-argument( @index ) ;
.loop( ( @index + 1 ) ; @normalize-argument ) ;
}
// For the NTH ARGUMENT in the list, we can safely append the sanitized argument
// to the incoming list since we know it has at least one established argument.
.loop( @index ; @running-list ) when ( @index <= @length ) and ( @index > 1 ) {
.normalize-argument( @index ) ;
// When calling the mixin recursively, append the normalized argument to the
// end of the running list.
.loop( ( @index + 1 ) ; @running-list, @normalize-argument ) ;
}
// Once we get past the length of the original arguments list, we know that the
// normalized arguments list is complete. As such, we just need to save it back
// into the parent mixin scope.
.loop( @index ; @running-list ) when ( @index > @length ) {
@loop: @running-list ;
}
// I extract and normalize the argument at the given index.
.normalize-argument( @index ) {
@rawArgument: extract( @args, @index ) ;
// Strip the surrounding quotes - we will quote the value when we "return" it.
@normalizedArgument: ~"@{rawArgument}" ;
// Escape any embedded quotes.
@escapedDoubleQuotes: replace( ~"@{normalizedArgument}", '"', '\"', "g" ) ;
@escapedSingleQuotes: replace( ~"@{escapedDoubleQuotes}", "'", "\'", "g" ) ;
// Further normalize the value by quoting and save it into the calling context.
@normalize-argument: "@{escapedSingleQuotes}" ;
}
}
// ---------------------------------------------------------- //
// ---------------------------------------------------------- //
// I simply output accept N-arguments to test with.
.test-arguments( ... ) {
// I take the arguments list and "return" the argument list in string format that
// is the JSON value of the stringified list. It will always ben array of values;
// and, each of the items in the list will be quoted. This allows each item to have
// embedded list delimiters.
.stringify-arguments( @arguments ) ;
// Invoke the JavaScript using variable interpolation.
content: ~`
(function( args ) {
return( JSON.stringify( args ) );
})( @{stringify-arguments} )
` ;
}
// Test various arguments list configurations.
body {
.test-arguments() ;
.test-arguments( a ;) ;
.test-arguments( a ; b ) ;
.test-arguments( "a", 'b', c ) ;
.test-arguments( "a", c, 'b' ) ;
.test-arguments( a, 'b' ; foo ; bar ) ;
.test-arguments( 'a, b' ; foo ; bar ) ;
.test-arguments( ~"a, b" ; foo ; bar ) ;
.test-arguments( ~'a, "b' ; url( "../../image.jpg" ) ) ;
.test-arguments( 4px 7px 4px 7px ) ;
.test-arguments( url( "background.png" ) 50% 50% no-repeat ) ;
.test-arguments( left 0.5s ease ) ;
.test-arguments( left 0.5s ease , top 1.0s ease ) ;
.test-arguments( ~"1 2 3" 4 5 ) ;
}
The code is a bit complicated; but, the .stringify-arguments mixin is looping over the arguments recursively, normalizing each argument, and folding (aka reducing) it back into a single list value. Then, I pass it into a JavaScript context for the sake of normalizing the data type before serializing it as JSON (JavaScript Object Notation).
When we compile the above Less CSS, we get the following CSS output:
body {
content: [];
content: ["a"];
content: ["a","b"];
content: ["a","b","c"];
content: ["a","c","b"];
content: ["a, 'b'","foo","bar"];
content: ["a, b","foo","bar"];
content: ["a, \"b","url(\"../../image.jpg\")"];
content: ["4px","7px","4px","7px"];
content: ["url(\"background.png\")","50%","50%","no-repeat"];
content: ["left","0.5s","ease"];
content: ["left 0.5s ease","top 1s ease"];
content: ["1 2 3","4","5"];
}
The output is a bit hard to read, but the .stringify-arguments mixin converted each arguments list into an array of double-quoted values. The Less CSS variable interpolation then forced JavaScript to parse the JSON into a native JavaScript array, which we are re-serializing for the sake of the demo.
This is probably overkill for the vast majority of use cases; but, since I've only just started playing with the advanced features of Less CSS, I'm not entirely sure how the JavaScript feature-set can be leveraged. If nothing else, this really forced me to examine how mixins interact and how values can be passed back-up through multiple mixin scopes.
Want to use code from this post? Check out the license.
Reader Comments