- Node Version: 24.19.0
- NPM Version: 11.17.0
- postcss Version: 8.5.26
- postcss-less Version: 6.0.0
Variable interpolation in an at-rule prelude - e.g. @media @{mq-xs} - throws CssSyntaxError: Unknown word. The same interpolation works fine in a selector position (@{mq-xs} { ... }), so the gap looks specific to at-rules.
LESS
@media @{mq-xs} { .a { color: red } }
@media @{mq-xs} and (min-width: 5px) { .a { color: red } }
/* these two parse fine today */
@media @mq-xs { .a { color: red } }
@{mq-xs} { .a { color: red } }
JavaScript
const postcssLess = require('postcss-less');
const samples = [
'@media @{mq-xs} { .a { color: red } }',
'@media @{mq-xs} and (min-width: 5px) { .a { color: red } }',
'@media @mq-xs { .a { color: red } }',
'@{mq-xs} { .a { color: red } }'
];
for (const css of samples) {
try {
const root = postcssLess.parse(css, { from: 'test.less' });
console.log('OK ', css, '->', JSON.stringify(root.first.params ?? root.first.selector));
} catch (error) {
console.log('THROWS', css, '->', error.message.split('\n')[0]);
}
}
Errors
THROWS @media @{mq-xs} { .a { color: red } }
-> test.less:1:10: Unknown word mq-xs
THROWS @media @{mq-xs} and (min-width: 5px) { .a { color: red } }
-> test.less:1:10: Unknown word mq-xs
OK @media @mq-xs { .a { color: red } } -> "@mq-xs"
OK @{mq-xs} { .a { color: red } } -> "@{mq-xs}"
Expected Behavior
@media @{mq-xs} { ... } parses into an AtRule with name === 'media' and params === '@{mq-xs}', mirroring how the already-supported selector case @{mq-xs} { ... } is handled.
Actual Behavior
A CssSyntaxError is thrown.
How can we reproduce the behavior?
mkdir repro && cd repro
npm init -y && npm install postcss-less@6.0.0
# save the JavaScript snippet above as repro.js
node repro.js
Analysis
The tokenizer emits the interpolation as three separate tokens:
at-word "@media" | space | at-word "@" | { | word "mq-xs" | } | space | {
PostCSS's Parser#atrule only tracks ( and [ in its brackets stack, so on the interpolation's { it takes brackets.length === 0 → open = true; break. The prelude is cut short and mq-xs ends up parsed as an unknown word inside the block.
LessParser#atrule calls interpolation() only on the at-rule's first token, which returns false for @media (length > 1), so interpolations in the prelude are never merged. LessParser#rule already works around the same problem for selectors - this is the missing at-rule counterpart.
A fix that merges @{...} token sequences in the prelude before delegating to super.atrule() resolves all cases above with no regressions on my side (@import, @plugin, variable declarations, mixin calls, detached rulesets, @supports, @media with variables inside features, and @@var indirection all still round-trip). Happy to open a PR with tests if that's welcome.
Why this matters now
Less >=4.7.x deprecates the bare form and points users at exactly the syntax that cannot be parsed:
DEPRECATED WARNING: A bare @variable in an at-rule prelude is deprecated.
Use @{variable} interpolation instead.
So any codebase migrating off the deprecation becomes unparseable by postcss-less, and therefore un-lintable by stylelint. In our case that's ~1800 occurrences across ~900 files.
Variable interpolation in an at-rule prelude - e.g.
@media @{mq-xs}- throwsCssSyntaxError: Unknown word. The same interpolation works fine in a selector position (@{mq-xs} { ... }), so the gap looks specific to at-rules.LESS
JavaScript
Errors
Expected Behavior
@media @{mq-xs} { ... }parses into anAtRulewithname === 'media'andparams === '@{mq-xs}', mirroring how the already-supported selector case@{mq-xs} { ... }is handled.Actual Behavior
A
CssSyntaxErroris thrown.How can we reproduce the behavior?
Analysis
The tokenizer emits the interpolation as three separate tokens:
PostCSS's
Parser#atruleonly tracks(and[in itsbracketsstack, so on the interpolation's{it takesbrackets.length === 0→open = true; break. The prelude is cut short andmq-xsends up parsed as an unknown word inside the block.LessParser#atrulecallsinterpolation()only on the at-rule's first token, which returnsfalsefor@media(length > 1), so interpolations in the prelude are never merged.LessParser#rulealready works around the same problem for selectors - this is the missing at-rule counterpart.A fix that merges
@{...}token sequences in the prelude before delegating tosuper.atrule()resolves all cases above with no regressions on my side (@import,@plugin, variable declarations, mixin calls, detached rulesets,@supports,@mediawith variables inside features, and@@varindirection all still round-trip). Happy to open a PR with tests if that's welcome.Why this matters now
Less >=4.7.x deprecates the bare form and points users at exactly the syntax that cannot be parsed:
So any codebase migrating off the deprecation becomes unparseable by
postcss-less, and therefore un-lintable by stylelint. In our case that's ~1800 occurrences across ~900 files.