Skip to content

Commit 147cbe3

Browse files
authored
[stylelint-plugin] Add @blueprintjs/prefer-spacing-variable rule (#7560)
1 parent 8bc7a9c commit 147cbe3

17 files changed

+610
-2
lines changed

.stylelintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"rules": {
1010
"@blueprintjs/no-prefix-literal": [true, { "disableFix": true }],
1111
"@blueprintjs/no-color-literal": [true, { "disableFix": true }],
12+
"@blueprintjs/prefer-spacing-variable": [true, { "disableFix": true }],
1213
"color-function-notation": "legacy",
1314
"declaration-empty-line-before": null,
1415
"no-invalid-position-at-import-rule": [true, {

packages/core/src/common/_variables-extended.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
// exported.
1010
// ----------------------------------------------------------------------------
1111

12+
/* stylelint-disable-next-line @blueprintjs/prefer-spacing-variable */
1213
$half-grid-size: $pt-grid-size * 0.5 !default;
1314

1415
// Extended color palette

packages/stylelint-plugin/README.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ Simply add this plugin in your `.stylelintrc` file and then pick the rules that
2727
"plugins": ["@blueprintjs/stylelint-plugin"],
2828
"rules": {
2929
"@blueprintjs/no-color-literal": true,
30-
"@blueprintjs/no-prefix-literal": true
30+
"@blueprintjs/no-prefix-literal": true,
31+
"@blueprintjs/prefer-spacing-variable": true
3132
}
3233
}
3334
```
@@ -93,4 +94,49 @@ Optional secondary options:
9394
- `disableFix: boolean` - if true, autofix will be disabled
9495
- `variablesImportPath: { less?: string, sass?: string }` - can be used to configure a custom path for importing Blueprint variables when autofixing.
9596

97+
### `@blueprintjs/prefer-spacing-variable` (autofixable)
98+
99+
Enforce usage of the new `$pt-spacing` variable instead of the deprecated `$pt-grid-size` variable.
100+
101+
Blueprint is migrating from a 10px-based grid system (`$pt-grid-size`) to a 4px-based spacing system (`$pt-spacing`) to provide more flexible spacing options and improve consistency. This rule helps automate the migration by detecting deprecated variable usage and automatically converting expressions with proper multiplier adjustments.
102+
103+
```json
104+
{
105+
"rules": {
106+
"@blueprintjs/prefer-spacing-variable": true
107+
}
108+
}
109+
```
110+
111+
```diff
112+
-.my-class {
113+
- padding: $pt-grid-size;
114+
- margin: $pt-grid-size * 2;
115+
- width: $pt-grid-size / 2;
116+
-}
117+
+.my-class {
118+
+ padding: $pt-spacing * 2.5;
119+
+ margin: $pt-spacing * 5;
120+
+ width: $pt-spacing / 0.8;
121+
+}
122+
```
123+
124+
The rule automatically converts mathematical expressions by applying the 2.5x conversion factor (since `$pt-grid-size` is 10px and `$pt-spacing` is 4px).
125+
126+
**Conversion examples:**
127+
128+
- `$pt-grid-size``$pt-spacing * 2.5`
129+
- `$pt-grid-size * 2``$pt-spacing * 5`
130+
- `2 * $pt-grid-size``5 * $pt-spacing`
131+
- `$pt-grid-size / 2``$pt-spacing / 0.8`
132+
- `bp.$pt-grid-size * 1.5``bp.$pt-spacing * 3.75`
133+
- `calc($pt-grid-size * 1.5)``calc($pt-spacing * 3.75)`
134+
135+
Optional secondary options:
136+
137+
- `disableFix: boolean` - if true, autofix will be disabled
138+
- `variablesImportPath: { less?: string, sass?: string }` - can be used to configure a custom path for importing Blueprint variables when autofixing.
139+
140+
**See also:** [Spacing System Migration Guide](https://github.com/palantir/blueprint/wiki/Spacing-System-Migration:-10px-to-4px)
141+
96142
### [Full Documentation](http://blueprintjs.com/docs) | [Source Code](https://github.com/palantir/blueprint)

packages/stylelint-plugin/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@
1515

1616
import noColorLiteral from "./rules/no-color-literal";
1717
import noPrefixLiteral from "./rules/no-prefix-literal";
18+
import preferSpacingVariable from "./rules/prefer-spacing-variable";
1819

19-
export = [noColorLiteral, noPrefixLiteral];
20+
export = [noColorLiteral, noPrefixLiteral, preferSpacingVariable];
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/*
2+
* Copyright 2025 Palantir Technologies, Inc. All rights reserved.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
import type { Declaration, Root } from "postcss";
17+
import valueParser from "postcss-value-parser";
18+
import stylelint, { type PostcssResult, type RuleContext } from "stylelint";
19+
20+
import { BpVariablePrefixMap, CssSyntax, getCssSyntax, isCssSyntaxToStringMap } from "../utils/cssSyntax";
21+
22+
const ruleName = "@blueprintjs/prefer-spacing-variable";
23+
24+
const GRID_SIZE_VARIABLE = "pt-grid-size";
25+
const SPACING_VARIABLE = "pt-spacing";
26+
27+
const messages = stylelint.utils.ruleMessages(ruleName, {
28+
expected: (unfixed: string, fixed: string) =>
29+
`Use \`${fixed}\` instead of \`${unfixed}\` (deprecated). See: https://github.com/palantir/blueprint/wiki/Spacing-System-Migration:-10px-to-4px`,
30+
});
31+
32+
interface Options {
33+
disableFix?: boolean;
34+
variablesImportPath?: Partial<Record<Exclude<CssSyntax, CssSyntax.OTHER>, string>>;
35+
}
36+
37+
const ruleImpl =
38+
(enabled: boolean, options: Options | undefined, context: RuleContext) => (root: Root, result: PostcssResult) => {
39+
if (!enabled) {
40+
return;
41+
}
42+
43+
const validOptions = stylelint.utils.validateOptions(
44+
result,
45+
ruleName,
46+
{
47+
actual: enabled,
48+
optional: false,
49+
possible: [true, false],
50+
},
51+
{
52+
actual: options,
53+
optional: true,
54+
possible: {
55+
disableFix: [true, false],
56+
variablesImportPath: [isCssSyntaxToStringMap],
57+
},
58+
},
59+
);
60+
61+
if (!validOptions) {
62+
return;
63+
}
64+
65+
const disableFix = options?.disableFix ?? false;
66+
67+
const cssSyntax = getCssSyntax(root.source?.input.file || "");
68+
if (cssSyntax === CssSyntax.OTHER) {
69+
return;
70+
}
71+
72+
const variablePrefix = BpVariablePrefixMap[cssSyntax];
73+
const gridSizeVariable = `${variablePrefix}${GRID_SIZE_VARIABLE}`;
74+
const spacingVariable = `${variablePrefix}${SPACING_VARIABLE}`;
75+
76+
root.walkDecls(decl => {
77+
// Skip declarations that don't contain grid-size variables
78+
if (!decl.value.includes(gridSizeVariable)) {
79+
return;
80+
}
81+
82+
if (context.fix && !disableFix) {
83+
// Convert the entire value using string replacement
84+
const newValue = convertGridSizeToSpacing(decl.value, gridSizeVariable, spacingVariable);
85+
decl.value = newValue;
86+
} else {
87+
// Report warnings for each variable usage
88+
const parsedValue = valueParser(decl.value);
89+
parsedValue.walk(node => {
90+
if (node.type !== "word") {
91+
return;
92+
}
93+
94+
const isGridSizeVariable = node.value.includes(gridSizeVariable);
95+
96+
if (isGridSizeVariable) {
97+
const namespace = getVarNamespace(node.value);
98+
const isNamespaced = namespace !== undefined;
99+
100+
const targetVar = isNamespaced ? `${namespace}.${spacingVariable}` : spacingVariable;
101+
102+
stylelint.utils.report({
103+
index: declarationValueIndex(decl) + node.sourceIndex,
104+
message: messages.expected(node.value, targetVar),
105+
node: decl,
106+
result,
107+
ruleName,
108+
});
109+
}
110+
});
111+
}
112+
});
113+
};
114+
115+
function getVarNamespace(value: string): string | undefined {
116+
if (value.includes(".")) {
117+
return value.split(".")[0];
118+
}
119+
return undefined;
120+
}
121+
122+
/**
123+
* Converts grid-size variables to spacing variables, maintains original computed value.
124+
*/
125+
function convertGridSizeToSpacing(value: string, gridVar: string, spacingVar: string): string {
126+
let result = value;
127+
128+
// Check if there's a namespaced variable (e.g., bp.$pt-grid-size)
129+
const namespacedMatch = value.match(new RegExp(`(\\w+)\\.\\$${GRID_SIZE_VARIABLE}`));
130+
131+
if (namespacedMatch) {
132+
// Process namespaced variable
133+
const namespace = namespacedMatch[1];
134+
const namespacedGridVar = `${namespace}.${gridVar}`;
135+
const namespacedSpacingVar = `${namespace}.${spacingVar}`;
136+
137+
result = processVariableConversions(result, namespacedGridVar, namespacedSpacingVar);
138+
} else {
139+
// Process non-namespaced variables
140+
result = processVariableConversions(result, gridVar, spacingVar);
141+
}
142+
143+
return result;
144+
}
145+
146+
/**
147+
* Process variable conversions for a specific variable pair
148+
*/
149+
function processVariableConversions(value: string, fromVar: string, toVar: string): string {
150+
let result = value;
151+
const escapedFrom = fromVar.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
152+
153+
// Handle right-side multiplication: $var * N -> $var * (N * 2.5)
154+
result = result.replace(new RegExp(`${escapedFrom}\\s*\\*\\s*(\\d*\\.?\\d+)`, "g"), (_match, multiplier) => {
155+
const newValue = parseFloat(multiplier) * 2.5;
156+
return `${toVar} * ${formatNumber(newValue)}`;
157+
});
158+
159+
// Handle left-side multiplication: N * $var -> (N * 2.5) * $var
160+
result = result.replace(new RegExp(`(\\d*\\.?\\d+)\\s*\\*\\s*${escapedFrom}`, "g"), (_match, multiplier) => {
161+
const newValue = parseFloat(multiplier) * 2.5;
162+
return `${formatNumber(newValue)} * ${toVar}`;
163+
});
164+
165+
// Handle division: $var / N -> $var / (N / 2.5)
166+
result = result.replace(new RegExp(`${escapedFrom}\\s*\\/\\s*(\\d*\\.?\\d+)`, "g"), (_match, divisor) => {
167+
const newValue = parseFloat(divisor) / 2.5;
168+
return `${toVar} / ${formatNumber(newValue)}`;
169+
});
170+
171+
// Handle simple variable replacement (no adjacent math operations)
172+
result = result.replace(new RegExp(`${escapedFrom}(?!\\s*[*/])`, "g"), `${toVar} * 2.5`);
173+
174+
return result;
175+
}
176+
177+
function formatNumber(num: number): string {
178+
return num % 1 === 0 ? num.toString() : num.toString();
179+
}
180+
181+
/**
182+
* Returns the index of the start of the declaration value.
183+
*/
184+
function declarationValueIndex(decl: Declaration): number {
185+
const beforeColon = decl.toString().indexOf(":");
186+
const afterColon = decl.raw("between").length - decl.raw("between").indexOf(":");
187+
return beforeColon + afterColon;
188+
}
189+
190+
ruleImpl.ruleName = ruleName;
191+
ruleImpl.messages = messages;
192+
193+
export default stylelint.createPlugin(ruleName, ruleImpl);
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.my-component {
2+
height: calc($pt-grid-size * 3 + 2px);
3+
margin-top: calc($pt-grid-size / 2);
4+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.my-component {
2+
// Multiple usages in one declaration
3+
padding: $pt-grid-size $pt-grid-size * 2;
4+
5+
// Mixed with other values
6+
margin: $pt-grid-size * 1.5 auto 10px;
7+
8+
// In complex calc expressions
9+
height: calc(100% - $pt-grid-size * 3);
10+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/* stylelint-disable @blueprintjs/prefer-spacing-variable */
2+
.my-component {
3+
padding: $pt-grid-size;
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.my-component {
2+
padding: $pt-grid-size / 2;
3+
margin: $pt-grid-size / 4;
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.my-component {
2+
padding: @pt-grid-size;
3+
margin: @pt-grid-size * 2;
4+
}

0 commit comments

Comments
 (0)