Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/tidy-pugs-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 55 additions & 0 deletions packages/postcss-linaria/__tests__/__utils__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
\`;
`,
};
61 changes: 61 additions & 0 deletions packages/postcss-linaria/__tests__/stringify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const {
declarationMultipleValues,
declarationMixedValues,
combo,
multilineValueInParens,
multilineValueInNestedParens,
multilineAtRuleParams,
multilineAtRuleParamsLeadingExpression,
atRuleExpressionInAfterName,
} = sourceWithExpression;

describe('stringify', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 27 additions & 1 deletion packages/postcss-linaria/src/locationCorrection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown>).linariaValue = rawValue;
Expand Down
45 changes: 41 additions & 4 deletions packages/postcss-linaria/src/stringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading