Skip to content

Commit eb7a42d

Browse files
Copilotsiddharthkp
andcommitted
Add namespace-spacing-utils rule for Primer CSS spacing utilities
Co-authored-by: siddharthkp <1863771+siddharthkp@users.noreply.github.com>
1 parent 542f49d commit eb7a42d

File tree

4 files changed

+369
-0
lines changed

4 files changed

+369
-0
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Enforce namespacing of spacing utility classes (namespace-spacing-utils)
2+
3+
Primer CSS spacing utility classes (margin and padding) should be namespaced with the `pr-` prefix to avoid conflicts with other CSS frameworks and ensure consistent styling.
4+
5+
## Rule details
6+
7+
This rule enforces that all Primer CSS spacing utility classes (margin and padding) in `className` attributes are prefixed with `pr-`.
8+
9+
### Spacing utility patterns
10+
11+
The following patterns are detected:
12+
13+
- Margin: `m-{size}`, `mx-{size}`, `my-{size}`, `mt-{size}`, `mr-{size}`, `mb-{size}`, `ml-{size}`
14+
- Padding: `p-{size}`, `px-{size}`, `py-{size}`, `pt-{size}`, `pr-{size}`, `pb-{size}`, `pl-{size}`
15+
- Sizes: `0-12`, `auto`, `n1-n12` (negative values)
16+
- Responsive variants: `sm:`, `md:`, `lg:`, `xl:` prefixes
17+
18+
👎 Examples of **incorrect** code for this rule:
19+
20+
```jsx
21+
/* eslint primer-react/namespace-spacing-utils: "error" */
22+
23+
// ❌ Margin classes without namespace
24+
<div className="m-4" />
25+
<div className="mx-2" />
26+
<div className="mt-1 mb-3" />
27+
28+
// ❌ Padding classes without namespace
29+
<div className="p-4" />
30+
<div className="px-2 py-3" />
31+
32+
// ❌ Negative spacing without namespace
33+
<div className="m-n4" />
34+
35+
// ❌ Auto spacing without namespace
36+
<div className="mx-auto" />
37+
38+
// ❌ Responsive variants without namespace
39+
<div className="md:m-4" />
40+
```
41+
42+
👍 Examples of **correct** code for this rule:
43+
44+
```jsx
45+
/* eslint primer-react/namespace-spacing-utils: "error" */
46+
47+
// ✅ Margin classes with namespace
48+
<div className="pr-m-4" />
49+
<div className="pr-mx-2" />
50+
<div className="pr-mt-1 pr-mb-3" />
51+
52+
// ✅ Padding classes with namespace
53+
<div className="pr-p-4" />
54+
<div className="pr-px-2 pr-py-3" />
55+
56+
// ✅ Negative spacing with namespace
57+
<div className="pr-m-n4" />
58+
59+
// ✅ Auto spacing with namespace
60+
<div className="pr-mx-auto" />
61+
62+
// ✅ Responsive variants with namespace
63+
<div className="md:pr-m-4" />
64+
65+
// ✅ Non-spacing classes are not affected
66+
<div className="text-bold color-fg-default" />
67+
```
68+
69+
## Options
70+
71+
This rule has no configuration options.
72+
73+
## When to use autofix
74+
75+
This rule includes an autofix that will automatically add the `pr-` prefix to unnamespaced spacing utility classes. The autofix is safe to use as it only modifies the class names that match the spacing utility patterns.

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module.exports = {
1111
'enforce-button-for-link-with-no-href': require('./rules/enforce-button-for-link-with-no-href'),
1212
'enforce-css-module-default-import': require('./rules/enforce-css-module-default-import'),
1313
'enforce-css-module-identifier-casing': require('./rules/enforce-css-module-identifier-casing'),
14+
'namespace-spacing-utils': require('./rules/namespace-spacing-utils'),
1415
'new-color-css-vars': require('./rules/new-color-css-vars'),
1516
'no-deprecated-entrypoints': require('./rules/no-deprecated-entrypoints'),
1617
'no-deprecated-experimental-components': require('./rules/no-deprecated-experimental-components'),
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
const rule = require('../namespace-spacing-utils')
2+
const {RuleTester} = require('eslint')
3+
4+
const ruleTester = new RuleTester({
5+
languageOptions: {
6+
ecmaVersion: 'latest',
7+
sourceType: 'module',
8+
parserOptions: {
9+
ecmaFeatures: {
10+
jsx: true,
11+
},
12+
},
13+
},
14+
})
15+
16+
ruleTester.run('namespace-spacing-utils', rule, {
17+
valid: [
18+
// Already namespaced margin classes
19+
'<div className="pr-m-4" />',
20+
'<div className="pr-mx-2" />',
21+
'<div className="pr-my-3" />',
22+
'<div className="pr-mt-1" />',
23+
'<div className="pr-mr-2" />',
24+
'<div className="pr-mb-3" />',
25+
'<div className="pr-ml-4" />',
26+
27+
// Already namespaced padding classes
28+
'<div className="pr-p-4" />',
29+
'<div className="pr-px-2" />',
30+
'<div className="pr-py-3" />',
31+
'<div className="pr-pt-1" />',
32+
'<div className="pr-pr-2" />',
33+
'<div className="pr-pb-3" />',
34+
'<div className="pr-pl-4" />',
35+
36+
// Multiple namespaced classes
37+
'<div className="pr-m-4 pr-p-2" />',
38+
'<div className="some-class pr-m-4 other-class" />',
39+
40+
// Negative spacing classes (already namespaced)
41+
'<div className="pr-m-n4" />',
42+
'<div className="pr-mt-n2" />',
43+
44+
// Auto spacing classes (already namespaced)
45+
'<div className="pr-m-auto" />',
46+
'<div className="pr-mx-auto" />',
47+
48+
// Non-spacing classes should be ignored
49+
'<div className="text-bold color-fg-default" />',
50+
'<div className="d-flex flex-row" />',
51+
52+
// Classes that look similar but aren't spacing utilities
53+
'<div className="my-custom-class" />',
54+
'<div className="padding-extra" />',
55+
'<div className="margin-custom" />',
56+
57+
// Empty className
58+
'<div className="" />',
59+
60+
// No className
61+
'<div />',
62+
63+
// Template literal with namespaced classes
64+
'<div className={`pr-m-4 pr-p-2`} />',
65+
66+
// Responsive variants (already namespaced)
67+
'<div className="md:pr-m-4" />',
68+
'<div className="lg:pr-p-2" />',
69+
],
70+
invalid: [
71+
// Basic margin classes without namespace
72+
{
73+
code: '<div className="m-4" />',
74+
output: '<div className="pr-m-4" />',
75+
errors: [{messageId: 'namespaceRequired', data: {className: 'm-4', replacement: 'pr-m-4'}}],
76+
},
77+
{
78+
code: '<div className="mx-2" />',
79+
output: '<div className="pr-mx-2" />',
80+
errors: [{messageId: 'namespaceRequired', data: {className: 'mx-2', replacement: 'pr-mx-2'}}],
81+
},
82+
{
83+
code: '<div className="my-3" />',
84+
output: '<div className="pr-my-3" />',
85+
errors: [{messageId: 'namespaceRequired', data: {className: 'my-3', replacement: 'pr-my-3'}}],
86+
},
87+
{
88+
code: '<div className="mt-1" />',
89+
output: '<div className="pr-mt-1" />',
90+
errors: [{messageId: 'namespaceRequired', data: {className: 'mt-1', replacement: 'pr-mt-1'}}],
91+
},
92+
{
93+
code: '<div className="mr-2" />',
94+
output: '<div className="pr-mr-2" />',
95+
errors: [{messageId: 'namespaceRequired', data: {className: 'mr-2', replacement: 'pr-mr-2'}}],
96+
},
97+
{
98+
code: '<div className="mb-3" />',
99+
output: '<div className="pr-mb-3" />',
100+
errors: [{messageId: 'namespaceRequired', data: {className: 'mb-3', replacement: 'pr-mb-3'}}],
101+
},
102+
{
103+
code: '<div className="ml-4" />',
104+
output: '<div className="pr-ml-4" />',
105+
errors: [{messageId: 'namespaceRequired', data: {className: 'ml-4', replacement: 'pr-ml-4'}}],
106+
},
107+
108+
// Basic padding classes without namespace
109+
{
110+
code: '<div className="p-4" />',
111+
output: '<div className="pr-p-4" />',
112+
errors: [{messageId: 'namespaceRequired', data: {className: 'p-4', replacement: 'pr-p-4'}}],
113+
},
114+
{
115+
code: '<div className="px-2" />',
116+
output: '<div className="pr-px-2" />',
117+
errors: [{messageId: 'namespaceRequired', data: {className: 'px-2', replacement: 'pr-px-2'}}],
118+
},
119+
{
120+
code: '<div className="py-3" />',
121+
output: '<div className="pr-py-3" />',
122+
errors: [{messageId: 'namespaceRequired', data: {className: 'py-3', replacement: 'pr-py-3'}}],
123+
},
124+
{
125+
code: '<div className="pt-1" />',
126+
output: '<div className="pr-pt-1" />',
127+
errors: [{messageId: 'namespaceRequired', data: {className: 'pt-1', replacement: 'pr-pt-1'}}],
128+
},
129+
{
130+
code: '<div className="pb-3" />',
131+
output: '<div className="pr-pb-3" />',
132+
errors: [{messageId: 'namespaceRequired', data: {className: 'pb-3', replacement: 'pr-pb-3'}}],
133+
},
134+
{
135+
code: '<div className="pl-4" />',
136+
output: '<div className="pr-pl-4" />',
137+
errors: [{messageId: 'namespaceRequired', data: {className: 'pl-4', replacement: 'pr-pl-4'}}],
138+
},
139+
140+
// Negative spacing classes
141+
{
142+
code: '<div className="m-n4" />',
143+
output: '<div className="pr-m-n4" />',
144+
errors: [{messageId: 'namespaceRequired', data: {className: 'm-n4', replacement: 'pr-m-n4'}}],
145+
},
146+
{
147+
code: '<div className="mt-n2" />',
148+
output: '<div className="pr-mt-n2" />',
149+
errors: [{messageId: 'namespaceRequired', data: {className: 'mt-n2', replacement: 'pr-mt-n2'}}],
150+
},
151+
152+
// Auto spacing classes
153+
{
154+
code: '<div className="m-auto" />',
155+
output: '<div className="pr-m-auto" />',
156+
errors: [{messageId: 'namespaceRequired', data: {className: 'm-auto', replacement: 'pr-m-auto'}}],
157+
},
158+
{
159+
code: '<div className="mx-auto" />',
160+
output: '<div className="pr-mx-auto" />',
161+
errors: [{messageId: 'namespaceRequired', data: {className: 'mx-auto', replacement: 'pr-mx-auto'}}],
162+
},
163+
164+
// Mixed with other classes
165+
{
166+
code: '<div className="some-class m-4 other-class" />',
167+
output: '<div className="some-class pr-m-4 other-class" />',
168+
errors: [{messageId: 'namespaceRequired', data: {className: 'm-4', replacement: 'pr-m-4'}}],
169+
},
170+
171+
// Multiple unnamespaced classes - reports first one
172+
{
173+
code: '<div className="m-4 p-2" />',
174+
output: '<div className="pr-m-4 p-2" />',
175+
errors: [
176+
{messageId: 'namespaceRequired', data: {className: 'm-4', replacement: 'pr-m-4'}},
177+
{messageId: 'namespaceRequired', data: {className: 'p-2', replacement: 'pr-p-2'}},
178+
],
179+
},
180+
181+
// Template literal
182+
{
183+
code: '<div className={`m-4`} />',
184+
output: '<div className={`pr-m-4`} />',
185+
errors: [{messageId: 'namespaceRequired', data: {className: 'm-4', replacement: 'pr-m-4'}}],
186+
},
187+
188+
// Responsive variants
189+
{
190+
code: '<div className="md:m-4" />',
191+
output: '<div className="md:pr-m-4" />',
192+
errors: [{messageId: 'namespaceRequired', data: {className: 'md:m-4', replacement: 'md:pr-m-4'}}],
193+
},
194+
{
195+
code: '<div className="lg:p-2" />',
196+
output: '<div className="lg:pr-p-2" />',
197+
errors: [{messageId: 'namespaceRequired', data: {className: 'lg:p-2', replacement: 'lg:pr-p-2'}}],
198+
},
199+
200+
// Size 0 classes
201+
{
202+
code: '<div className="m-0" />',
203+
output: '<div className="pr-m-0" />',
204+
errors: [{messageId: 'namespaceRequired', data: {className: 'm-0', replacement: 'pr-m-0'}}],
205+
},
206+
{
207+
code: '<div className="p-0" />',
208+
output: '<div className="pr-p-0" />',
209+
errors: [{messageId: 'namespaceRequired', data: {className: 'p-0', replacement: 'pr-p-0'}}],
210+
},
211+
],
212+
})
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Regex pattern to match margin/padding utility classes from Primer CSS
2+
// Matches: m-{size}, mx-{size}, my-{size}, mt-{size}, mr-{size}, mb-{size}, ml-{size}
3+
// p-{size}, px-{size}, py-{size}, pt-{size}, pr-{size}, pb-{size}, pl-{size}
4+
// Sizes: 0-12, auto, n1-n12 (negative), responsive variants like md:m-4
5+
const spacingUtilPattern = /(?<!\S)(?:(?:sm|md|lg|xl):)?([mp][xytblr]?-(?:auto|n?[0-9]+))(?!\S)/g
6+
7+
// Process a class name string and find unnamespaced spacing utilities
8+
const findUnNamespacedClasses = classNameStr => {
9+
const matches = []
10+
let match
11+
spacingUtilPattern.lastIndex = 0 // Reset regex state
12+
while ((match = spacingUtilPattern.exec(classNameStr)) !== null) {
13+
const fullMatch = match[0]
14+
// Check if it's already namespaced (has pr- prefix)
15+
// We need to check the position before the match for "pr-"
16+
const startIndex = match.index
17+
const prefix = classNameStr.slice(Math.max(0, startIndex - 3), startIndex)
18+
if (!prefix.endsWith('pr-')) {
19+
matches.push({
20+
original: fullMatch,
21+
replacement: fullMatch.includes(':') ? fullMatch.replace(/:([mp])/, ':pr-$1') : `pr-${fullMatch}`,
22+
index: startIndex,
23+
})
24+
}
25+
}
26+
return matches
27+
}
28+
29+
module.exports = {
30+
meta: {
31+
type: 'suggestion',
32+
fixable: 'code',
33+
schema: [],
34+
docs: {
35+
description: 'Enforce namespacing of Primer CSS spacing utility classes (margin/padding) with the `pr-` prefix.',
36+
},
37+
messages: {
38+
namespaceRequired: 'Primer CSS spacing utility class "{{className}}" should be namespaced as "{{replacement}}".',
39+
},
40+
},
41+
create(context) {
42+
const reportUnNamespacedClasses = (node, classNameStr, valueNode) => {
43+
const unNamespacedClasses = findUnNamespacedClasses(classNameStr)
44+
45+
for (const {original, replacement} of unNamespacedClasses) {
46+
context.report({
47+
node: valueNode,
48+
messageId: 'namespaceRequired',
49+
data: {
50+
className: original,
51+
replacement,
52+
},
53+
fix(fixer) {
54+
// Get the raw text of the value node
55+
const sourceCode = context.sourceCode
56+
const rawText = sourceCode.getText(valueNode)
57+
58+
// Replace the unnamespaced class with the namespaced version
59+
const fixedText = rawText.replace(original, replacement)
60+
return fixer.replaceText(valueNode, fixedText)
61+
},
62+
})
63+
}
64+
}
65+
66+
return {
67+
// Handle className="..." (string literal)
68+
'JSXAttribute[name.name="className"] Literal': function (node) {
69+
if (typeof node.value === 'string') {
70+
reportUnNamespacedClasses(node, node.value, node)
71+
}
72+
},
73+
// Handle className={`...`} (template literal)
74+
'JSXAttribute[name.name="className"] TemplateLiteral TemplateElement': function (node) {
75+
if (node.value && typeof node.value.raw === 'string') {
76+
reportUnNamespacedClasses(node, node.value.raw, node)
77+
}
78+
},
79+
}
80+
},
81+
}

0 commit comments

Comments
 (0)