Skip to content

Commit 62ed92c

Browse files
authored
Improve prefer-style-directive rule (#111)
1 parent 3663fa8 commit 62ed92c

File tree

10 files changed

+256
-56
lines changed

10 files changed

+256
-56
lines changed

src/rules/prefer-style-directive.ts

Lines changed: 187 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { AST } from "svelte-eslint-parser"
2+
import type * as ESTree from "estree"
3+
import type { Root } from "postcss"
24
import { parse as parseCss } from "postcss"
35
import { createRule } from "../utils"
46

@@ -11,6 +13,13 @@ function safeParseCss(cssCode: string) {
1113
}
1214
}
1315

16+
/** Checks wether the given node is string literal or not */
17+
function isStringLiteral(
18+
node: ESTree.Expression,
19+
): node is ESTree.Literal & { value: string } {
20+
return node.type === "Literal" && typeof node.value === "string"
21+
}
22+
1423
export default createRule("prefer-style-directive", {
1524
meta: {
1625
docs: {
@@ -27,6 +36,179 @@ export default createRule("prefer-style-directive", {
2736
},
2837
create(context) {
2938
const sourceCode = context.getSourceCode()
39+
40+
/**
41+
* Process for `style=" ... "`
42+
*/
43+
function processStyleValue(
44+
node: AST.SvelteAttribute,
45+
root: Root,
46+
mustacheTags: AST.SvelteMustacheTagText[],
47+
) {
48+
const valueStartIndex = node.value[0].range[0]
49+
50+
root.walkDecls((decl) => {
51+
if (
52+
node.parent.attributes.some(
53+
(attr) =>
54+
attr.type === "SvelteStyleDirective" &&
55+
attr.key.name.name === decl.prop,
56+
)
57+
) {
58+
// has style directive
59+
return
60+
}
61+
62+
const declRange: AST.Range = [
63+
valueStartIndex + decl.source!.start!.offset,
64+
valueStartIndex + decl.source!.end!.offset + 1,
65+
]
66+
if (
67+
mustacheTags.some(
68+
(tag) =>
69+
(tag.range[0] < declRange[0] && declRange[0] < tag.range[1]) ||
70+
(tag.range[0] < declRange[1] && declRange[1] < tag.range[1]),
71+
)
72+
) {
73+
// intersection
74+
return
75+
}
76+
const declValueStartIndex =
77+
declRange[0] + decl.prop.length + (decl.raws.between || "").length
78+
const declValueRange: AST.Range = [
79+
declValueStartIndex,
80+
declValueStartIndex + (decl.raws.value?.value || decl.value).length,
81+
]
82+
83+
context.report({
84+
node,
85+
messageId: "unexpected",
86+
*fix(fixer) {
87+
const styleDirective = `style:${decl.prop}="${sourceCode.text.slice(
88+
...declValueRange,
89+
)}"`
90+
if (root.nodes.length === 1 && root.nodes[0] === decl) {
91+
yield fixer.replaceTextRange(node.range, styleDirective)
92+
} else {
93+
yield fixer.removeRange(declRange)
94+
yield fixer.insertTextAfterRange(node.range, ` ${styleDirective}`)
95+
}
96+
},
97+
})
98+
})
99+
}
100+
101+
/**
102+
* Process for `style="{a ? 'color: red;': ''}"`
103+
*/
104+
function processMustacheTags(
105+
mustacheTags: AST.SvelteMustacheTagText[],
106+
attrNode: AST.SvelteAttribute,
107+
) {
108+
for (const mustacheTag of mustacheTags) {
109+
processMustacheTag(mustacheTag, attrNode)
110+
}
111+
}
112+
113+
/**
114+
* Process for `style="{a ? 'color: red;': ''}"`
115+
*/
116+
function processMustacheTag(
117+
mustacheTag: AST.SvelteMustacheTagText,
118+
attrNode: AST.SvelteAttribute,
119+
) {
120+
const node = mustacheTag.expression
121+
122+
if (node.type !== "ConditionalExpression") {
123+
return
124+
}
125+
if (
126+
!isStringLiteral(node.consequent) ||
127+
!isStringLiteral(node.alternate)
128+
) {
129+
return
130+
}
131+
if (node.consequent.value && node.alternate.value) {
132+
// e.g. t ? 'top: 20px' : 'left: 30px'
133+
return
134+
}
135+
const positive = node.alternate.value === ""
136+
const root = safeParseCss(
137+
positive ? node.consequent.value : node.alternate.value,
138+
)
139+
if (!root || root.nodes.length !== 1) {
140+
return
141+
}
142+
const decl = root.nodes[0]
143+
if (decl.type !== "decl") {
144+
return
145+
}
146+
if (
147+
attrNode.parent.attributes.some(
148+
(attr) =>
149+
attr.type === "SvelteStyleDirective" &&
150+
attr.key.name.name === decl.prop,
151+
)
152+
) {
153+
// has style directive
154+
return
155+
}
156+
157+
context.report({
158+
node,
159+
messageId: "unexpected",
160+
*fix(fixer) {
161+
let valueText = sourceCode.text.slice(
162+
node.test.range![0],
163+
node.consequent.range![0],
164+
)
165+
if (positive) {
166+
valueText +=
167+
sourceCode.text[node.consequent.range![0]] +
168+
decl.value +
169+
sourceCode.text[node.consequent.range![1] - 1]
170+
} else {
171+
valueText += "null"
172+
}
173+
valueText += sourceCode.text.slice(
174+
node.consequent.range![1],
175+
node.alternate.range![0],
176+
)
177+
if (positive) {
178+
valueText += "null"
179+
} else {
180+
valueText +=
181+
sourceCode.text[node.alternate.range![0]] +
182+
decl.value +
183+
sourceCode.text[node.alternate.range![1] - 1]
184+
}
185+
const styleDirective = `style:${decl.prop}={${valueText}}`
186+
if (
187+
attrNode.value
188+
.filter((v) => v !== mustacheTag)
189+
.every((v) => v.type === "SvelteLiteral" && !v.value.trim())
190+
) {
191+
yield fixer.replaceTextRange(attrNode.range, styleDirective)
192+
} else {
193+
const first = attrNode.value[0]
194+
if (first !== mustacheTag) {
195+
yield fixer.replaceTextRange(
196+
[first.range[0], mustacheTag.range[0]],
197+
sourceCode.text
198+
.slice(first.range[0], mustacheTag.range[0])
199+
.trimEnd(),
200+
)
201+
}
202+
yield fixer.removeRange(mustacheTag.range)
203+
yield fixer.insertTextAfterRange(
204+
attrNode.range,
205+
` ${styleDirective}`,
206+
)
207+
}
208+
},
209+
})
210+
}
211+
30212
return {
31213
"SvelteStartTag > SvelteAttribute"(
32214
node: AST.SvelteAttribute & {
@@ -37,9 +219,8 @@ export default createRule("prefer-style-directive", {
37219
return
38220
}
39221
const mustacheTags = node.value.filter(
40-
(v) => v.type === "SvelteMustacheTag",
222+
(v): v is AST.SvelteMustacheTagText => v.type === "SvelteMustacheTag",
41223
)
42-
const valueStartIndex = node.value[0].range[0]
43224
const cssCode = node.value
44225
.map((value) => {
45226
if (value.type === "SvelteMustacheTag") {
@@ -49,61 +230,11 @@ export default createRule("prefer-style-directive", {
49230
})
50231
.join("")
51232
const root = safeParseCss(cssCode)
52-
if (!root) {
53-
return
233+
if (root) {
234+
processStyleValue(node, root, mustacheTags)
235+
} else {
236+
processMustacheTags(mustacheTags, node)
54237
}
55-
root.walkDecls((decl) => {
56-
if (
57-
node.parent.attributes.some(
58-
(attr) =>
59-
attr.type === "SvelteStyleDirective" &&
60-
attr.key.name.name === decl.prop,
61-
)
62-
) {
63-
// has style directive
64-
return
65-
}
66-
67-
const declRange: AST.Range = [
68-
valueStartIndex + decl.source!.start!.offset,
69-
valueStartIndex + decl.source!.end!.offset + 1,
70-
]
71-
if (
72-
mustacheTags.some(
73-
(tag) =>
74-
(tag.range[0] < declRange[0] && declRange[0] < tag.range[1]) ||
75-
(tag.range[0] < declRange[1] && declRange[1] < tag.range[1]),
76-
)
77-
) {
78-
// intersection
79-
return
80-
}
81-
const declValueStartIndex =
82-
declRange[0] + decl.prop.length + (decl.raws.between || "").length
83-
const declValueRange: AST.Range = [
84-
declValueStartIndex,
85-
declValueStartIndex + (decl.raws.value?.value || decl.value).length,
86-
]
87-
88-
context.report({
89-
node,
90-
messageId: "unexpected",
91-
*fix(fixer) {
92-
const styleDirective = `style:${
93-
decl.prop
94-
}="${sourceCode.text.slice(...declValueRange)}"`
95-
if (root.nodes.length === 1 && root.nodes[0] === decl) {
96-
yield fixer.replaceTextRange(node.range, styleDirective)
97-
} else {
98-
yield fixer.removeRange(declRange)
99-
yield fixer.insertTextAfterRange(
100-
node.range,
101-
` ${styleDirective}`,
102-
)
103-
}
104-
},
105-
})
106-
})
107238
},
108239
}
109240
},
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[
2+
{
3+
"message": "Can use style directives instead.",
4+
"line": 4,
5+
"column": 6
6+
},
7+
{
8+
"message": "Can use style directives instead.",
9+
"line": 5,
10+
"column": 6
11+
},
12+
{
13+
"message": "Can use style directives instead.",
14+
"line": 9,
15+
"column": 13
16+
}
17+
]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<div
2+
style="
3+
position: {position};
4+
{position === 'absolute' ? 'top: 20px;' : ''}
5+
{pointerEvents === false ? 'pointer-events:none;' : ''}
6+
"
7+
/>
8+
9+
<div style={position === "absolute" ? "top: 20px;" : ""} />
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<div
2+
style="
3+
position: {position};
4+
{pointerEvents === false ? 'pointer-events:none;' : ''}
5+
" style:top={position === 'absolute' ? '20px' : null}
6+
/>
7+
8+
<div style:top={position === "absolute" ? "20px" : null} />
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[
2+
{
3+
"message": "Can use style directives instead.",
4+
"line": 4,
5+
"column": 6
6+
}
7+
]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<div
2+
style="
3+
position: {position};
4+
{pointerEvents === false ? 'pointer-events:none;' : ''}
5+
"
6+
style:top={position === "absolute" ? "20px" : null}
7+
/>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div
2+
style="
3+
position: {position};
4+
" style:pointer-events={pointerEvents === false ? 'none' : null}
5+
style:top={position === "absolute" ? "20px" : null}
6+
/>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[
2+
{
3+
"message": "Can use style directives instead.",
4+
"line": 3,
5+
"column": 6
6+
}
7+
]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<div
2+
style="
3+
{pointerEvents ? '' : 'pointer-events:none'}
4+
"
5+
/>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<div
2+
style:pointer-events={pointerEvents ? null : 'none'}
3+
/>

0 commit comments

Comments
 (0)