From c3a0f90ee3fcb2b0abffeaf35dcb5c5fe79e284d Mon Sep 17 00:00:00 2001 From: Anton Evzhakov Date: Fri, 31 Jul 2026 13:55:54 +0300 Subject: [PATCH] fix(postcss): preserve interpolations and indentation through stylelint --fix --- .changeset/tidy-pugs-repeat.md | 11 ++++ .../__tests__/__utils__/index.ts | 55 +++++++++++++++++ .../__tests__/stringify.test.ts | 61 +++++++++++++++++++ .../postcss-linaria/src/locationCorrection.ts | 28 ++++++++- packages/postcss-linaria/src/stringify.ts | 45 ++++++++++++-- 5 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 .changeset/tidy-pugs-repeat.md diff --git a/.changeset/tidy-pugs-repeat.md b/.changeset/tidy-pugs-repeat.md new file mode 100644 index 000000000..bcafb39af --- /dev/null +++ b/.changeset/tidy-pugs-repeat.md @@ -0,0 +1,11 @@ +--- +'@linaria/postcss-linaria': patch +--- + +Fix interpolations and indentation being mangled by `stylelint --fix` + +An interpolation alone on its own line gets a comment placeholder. Inside a parenthesised value PostCSS keeps that comment only in `raws.value.raw`, and the re-indented value was read from `value`, so the placeholder was gone by the time the stringifier ran, and the interpolation with it (#1494). + +Multi-line at-rule params were corrupted rather than dropped: `super.atrule` re-reads params through `rawValue`, so substituting into `node.params` was discarded and `@media screen and ${query}` came out as `@media screen and .pcss-lin0`. An interpolation on the line after the at-rule name lands in the `afterName` raw and was emitted verbatim for the same reason. + +A newline inside `afterName` also never had its base indentation restored, so a wrapped at-rule prelude lost its leading whitespace on any fix, templates with no interpolations at all included. diff --git a/packages/postcss-linaria/__tests__/__utils__/index.ts b/packages/postcss-linaria/__tests__/__utils__/index.ts index 56760bd69..6957c4ac7 100644 --- a/packages/postcss-linaria/__tests__/__utils__/index.ts +++ b/packages/postcss-linaria/__tests__/__utils__/index.ts @@ -129,4 +129,59 @@ export const sourceWithExpression = { \${expr4} \`; `, + multilineValueInParens: ` + const expr1 = 'gray'; + const expr2 = 'transparent'; + css\` + .foo { + background: linear-gradient( + 0deg, + \${expr1} 20%, + \${expr2} + ) + } + \`; + `, + multilineValueInNestedParens: ` + const expr1 = '10px'; + const expr2 = '20px'; + css\` + .foo { + width: calc( + 100% - max( + \${expr1}, + \${expr2} + ) + ) + } + \`; + `, + multilineAtRuleParams: ` + const expr = '(min-width: 100px)'; + css\` + @media screen and + \${expr} { + .foo { color: black; } + } + \`; + `, + multilineAtRuleParamsLeadingExpression: ` + const expr = 'screen'; + css\` + @media \${expr} and + (min-width: 100px) { + .foo { color: black; } + } + \`; + `, + atRuleExpressionInAfterName: ` + const expr = 'screen'; + css\` + @media + \${expr} and + (min-width: 100px) { + .foo { color: black; } + } + \`; + `, }; diff --git a/packages/postcss-linaria/__tests__/stringify.test.ts b/packages/postcss-linaria/__tests__/stringify.test.ts index 2ae6c5def..66de97ec4 100644 --- a/packages/postcss-linaria/__tests__/stringify.test.ts +++ b/packages/postcss-linaria/__tests__/stringify.test.ts @@ -16,6 +16,11 @@ const { declarationMultipleValues, declarationMixedValues, combo, + multilineValueInParens, + multilineValueInNestedParens, + multilineAtRuleParams, + multilineAtRuleParamsLeadingExpression, + atRuleExpressionInAfterName, } = sourceWithExpression; describe('stringify', () => { @@ -79,6 +84,62 @@ describe('stringify', () => { const output = ast.toString(syntax); expect(output).toEqual(source); }); + + // https://github.com/callstack/linaria/issues/1494. An expression alone on + // its own line gets a comment placeholder, and PostCSS keeps that comment + // only in `raws.value.raw`. Reading `value` instead used to drop it. + it('should stringify an expression on its own line inside a parenthesised value', () => { + const { source, ast } = createTestAst(multilineValueInParens); + const output = ast.toString(syntax); + expect(output).toEqual(source); + }); + + it('should stringify expressions inside nested parenthesised values', () => { + const { source, ast } = createTestAst(multilineValueInNestedParens); + const output = ast.toString(syntax); + expect(output).toEqual(source); + }); + + // `super.atrule` re-reads the params through `rawValue`, so substituting + // into `node.params` alone used to be discarded for multi-line params. + it('should stringify an expression at the end of multi-line at-rule params', () => { + const { source, ast } = createTestAst(multilineAtRuleParams); + const output = ast.toString(syntax); + expect(output).toEqual(source); + }); + + it('should stringify an expression at the start of multi-line at-rule params', () => { + const { source, ast } = createTestAst( + multilineAtRuleParamsLeadingExpression + ); + const output = ast.toString(syntax); + expect(output).toEqual(source); + }); + + // An expression on the line right after the at-rule name lands in the + // `afterName` raw instead of the params, and `super.atrule` emits that raw + // verbatim without going through `raw()`. + it('should stringify an expression that lands in the at-rule afterName raw', () => { + const { source, ast } = createTestAst(atRuleExpressionInAfterName); + const output = ast.toString(syntax); + expect(output).toEqual(source); + }); + }); + + // A newline inside `afterName` needs its base indentation restored just like + // `before`, `between`, and the params do, expressions or not. + it('should keep the indentation of a wrapped at-rule prelude', () => { + const { source, ast } = createTestAst(` + css\` + @media + screen and (min-width: 100px) { + .foo { color: hotpink; } + } + \`; + `); + + const output = ast.toString(syntax); + expect(output).toEqual(source); }); it('should stringify basic CSS', () => { diff --git a/packages/postcss-linaria/src/locationCorrection.ts b/packages/postcss-linaria/src/locationCorrection.ts index 349787a9d..aebaa58c0 100644 --- a/packages/postcss-linaria/src/locationCorrection.ts +++ b/packages/postcss-linaria/src/locationCorrection.ts @@ -221,6 +221,21 @@ function computeBeforeAfter( node.raws.linariaBetween = corrected; } + if ( + node.type === 'atrule' && + typeof node.raws.afterName === 'string' && + node.raws.afterName.includes('\n') && + node.source?.start + ) { + const corrected = computeCorrectedString( + node.raws.afterName, + node.source.start.line, + baseIndentations + ); + + node.raws.linariaAfterName = corrected; + } + if (node.type === 'rule' && node.selector.includes('\n')) { const rawValue = computeCorrectedRawValue( node, @@ -235,7 +250,18 @@ function computeBeforeAfter( } if (node.type === 'decl' && node.value.includes('\n')) { - const rawValue = computeCorrectedRawValue(node, 'value', baseIndentations); + // PostCSS strips comments out of `value` but keeps them in `raws.value.raw`. + // A comment placeholder ends up inside the value when the declaration spans + // lines within parentheses, so read the raw form: taking `value` here drops + // the placeholder and the interpolation cannot be restored on stringify. + const rawSource = node.raws.value?.raw ?? node.value; + const rawValue = node.source?.start + ? computeCorrectedString( + rawSource, + node.source.start.line, + baseIndentations + ) + : null; if (rawValue !== null) { (node.raws as unknown as Record).linariaValue = rawValue; diff --git a/packages/postcss-linaria/src/stringify.ts b/packages/postcss-linaria/src/stringify.ts index 4998b0caf..e98e5796d 100644 --- a/packages/postcss-linaria/src/stringify.ts +++ b/packages/postcss-linaria/src/stringify.ts @@ -13,6 +13,11 @@ import Stringifier from 'postcss/lib/stringifier'; import { placeholderText } from './util'; +const commentPlaceholderPattern = new RegExp( + `/\\*\\s*${placeholderText}:(\\d+)\\s*\\*/`, + 'g' +); + const substitutePlaceholders = ( stringWithPlaceholders: string, expressions: string[] @@ -21,7 +26,15 @@ const substitutePlaceholders = ( return stringWithPlaceholders; } - const values = stringWithPlaceholders.split(' '); + // A comment placeholder reaches this point when it sits inside a declaration + // value rather than standing on its own as a comment node. The scan below + // splits on spaces, which cannot see it: the delimiters are separate tokens. + const substituted = stringWithPlaceholders.replace( + commentPlaceholderPattern, + (match, index: string) => expressions[Number(index)] ?? match + ); + + const values = substituted.split(' '); const temp: string[] = []; values.forEach((val) => { let [prefix, expressionIndexString] = val.split(placeholderText); @@ -84,12 +97,36 @@ class LinariaStringifier extends Stringifier { } public override atrule(node: AtRule, semicolon?: boolean) { - const { params } = node; - + // Unlike `decl` and `rule`, this method hands the params back to + // `super.atrule`, which re-reads them through `rawValue`, and that prefers + // `raws.linariaParams`. Substituting into `node.params` alone is therefore + // discarded whenever the params span several lines, so read and write the + // same place the stringifier will. + const params = this.rawValue(node, 'params'); const expressionStrings = node.root().raws.linariaTemplateExpressions; + if (params.includes(placeholderText)) { + const substituted = substitutePlaceholders(params, expressionStrings); + + if (node.raws.linariaParams === undefined) { + // eslint-disable-next-line no-param-reassign + node.params = substituted; + } else { + // eslint-disable-next-line no-param-reassign + node.raws.linariaParams = substituted; + } + } + + // `super.atrule` reads `raws.afterName` straight off the node instead of + // going through `raw()`, so both the re-indented form and any placeholder + // substitution have to be written back onto it here. An interpolation on + // the line after the at-rule name lands in this raw rather than the params. + const afterName = node.raws.linariaAfterName ?? node.raws.afterName; + if (typeof afterName === 'string') { // eslint-disable-next-line no-param-reassign - node.params = substitutePlaceholders(params, expressionStrings); + node.raws.afterName = afterName.includes(placeholderText) + ? substitutePlaceholders(afterName, expressionStrings) + : afterName; } super.atrule(node, semicolon);