Skip to content

Commit d2fb59c

Browse files
authored
Add html-quotes rule (#33)
1 parent 3fc5481 commit d2fb59c

30 files changed

+746
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ These rules relate to style guidelines, and are therefore quite subjective:
272272

273273
| Rule ID | Description | |
274274
|:--------|:------------|:---|
275+
| [@ota-meshi/svelte/html-quotes](https://ota-meshi.github.io/eslint-plugin-svelte/rules/html-quotes.html) | enforce quotes style of HTML attributes | :wrench: |
275276
| [@ota-meshi/svelte/indent](https://ota-meshi.github.io/eslint-plugin-svelte/rules/indent.html) | enforce consistent indentation | :wrench: |
276277
| [@ota-meshi/svelte/max-attributes-per-line](https://ota-meshi.github.io/eslint-plugin-svelte/rules/max-attributes-per-line.html) | enforce the maximum number of attributes per line | :wrench: |
277278
| [@ota-meshi/svelte/prefer-class-directive](https://ota-meshi.github.io/eslint-plugin-svelte/rules/prefer-class-directive.html) | require class directives instead of ternary expressions | :wrench: |

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ These rules relate to style guidelines, and are therefore quite subjective:
4242

4343
| Rule ID | Description | |
4444
|:--------|:------------|:---|
45+
| [@ota-meshi/svelte/html-quotes](./html-quotes.md) | enforce quotes style of HTML attributes | :wrench: |
4546
| [@ota-meshi/svelte/indent](./indent.md) | enforce consistent indentation | :wrench: |
4647
| [@ota-meshi/svelte/max-attributes-per-line](./max-attributes-per-line.md) | enforce the maximum number of attributes per line | :wrench: |
4748
| [@ota-meshi/svelte/prefer-class-directive](./prefer-class-directive.md) | require class directives instead of ternary expressions | :wrench: |

docs/rules/html-quotes.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "@ota-meshi/svelte/html-quotes"
5+
description: "enforce quotes style of HTML attributes"
6+
---
7+
8+
# @ota-meshi/svelte/html-quotes
9+
10+
> enforce quotes style of HTML attributes
11+
12+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> **_This rule has not been released yet._** </badge>
13+
- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
14+
15+
## :book: Rule Details
16+
17+
You can choose quotes of HTML attributes from:
18+
19+
- Double quotes: `<div class="foo">`
20+
- Single quotes: `<div class='foo'>`
21+
- No quotes: `<div class=foo>`
22+
23+
This rule enforces the quotes style of HTML attributes.
24+
25+
<eslint-code-block fix>
26+
27+
<!--eslint-skip-->
28+
29+
```html
30+
<script>
31+
/* eslint @ota-meshi/svelte/html-quotes: "error" */
32+
</script>
33+
34+
<!-- ✓ GOOD -->
35+
<input type="text" bind:value="{text}" />
36+
<img src="{src}" alt="{name} dances." />
37+
38+
<!-- ✗ BAD -->
39+
<input type="text" bind:value="{text}" />
40+
<img src="{src}" alt="{name} dances." />
41+
```
42+
43+
</eslint-code-block>
44+
45+
## :wrench: Options
46+
47+
```json
48+
{
49+
"@ota-meshi/svelte/html-quotes": [
50+
"error",
51+
{
52+
"prefer": "double", // or "single"
53+
"dynamic": {
54+
"quoted": false,
55+
"avoidInvalidUnquotedInHTML": false
56+
}
57+
}
58+
]
59+
}
60+
```
61+
62+
- `prefer` ... If `"double"`, requires double quotes. If `"single"` requires single quotes.
63+
- `dynamic` ... Settings for dynamic attribute values and directive values using curly braces.
64+
- `quoted` ... If `true`, enforce the use of quotes. If `false`, do not allow the use of quotes. The default is `false`.
65+
- `avoidInvalidUnquotedInHTML` ... If `true`, enforces the use of quotes if they are invalid as HTML attribute when not using quotes. The default is `false`.
66+
67+
## :mag: Implementation
68+
69+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/src/rules/html-quotes.ts)
70+
- [Test source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/tests/src/rules/html-quotes.ts)

src/rules/html-quotes.ts

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import type { AST } from "svelte-eslint-parser"
2+
import { isNotClosingBraceToken, isNotOpeningBraceToken } from "eslint-utils"
3+
import type { NodeOrToken } from "../types"
4+
import { createRule } from "../utils"
5+
6+
const QUOTE_CHARS = {
7+
double: '"',
8+
single: "'",
9+
}
10+
const QUOTE_NAMES = {
11+
double: "double quotes",
12+
single: "single quotes",
13+
unquoted: "unquoted",
14+
}
15+
16+
export default createRule("html-quotes", {
17+
meta: {
18+
docs: {
19+
description: "enforce quotes style of HTML attributes",
20+
category: "Stylistic Issues",
21+
recommended: false,
22+
},
23+
fixable: "code",
24+
schema: [
25+
{
26+
type: "object",
27+
properties: {
28+
prefer: { enum: ["double", "single"] },
29+
dynamic: {
30+
type: "object",
31+
properties: {
32+
quoted: { type: "boolean" },
33+
avoidInvalidUnquotedInHTML: { type: "boolean" },
34+
},
35+
additionalProperties: false,
36+
},
37+
},
38+
additionalProperties: false,
39+
},
40+
],
41+
messages: {
42+
expectedEnclosed: "Expected to be enclosed by quotes.",
43+
expectedEnclosedBy: "Expected to be enclosed by {{kind}}.",
44+
unexpectedEnclosed: "Unexpected to be enclosed by any quotes.",
45+
},
46+
type: "layout", // "problem",
47+
},
48+
create(context) {
49+
const sourceCode = context.getSourceCode()
50+
const preferQuote: "double" | "single" =
51+
context.options[0]?.prefer ?? "double"
52+
const dynamicQuote = context.options[0]?.dynamic?.quoted
53+
? preferQuote
54+
: "unquoted"
55+
const avoidInvalidUnquotedInHTML = Boolean(
56+
context.options[0]?.dynamic?.avoidInvalidUnquotedInHTML,
57+
)
58+
59+
type QuoteAndRange = {
60+
quote: "unquoted" | "double" | "single"
61+
range: [number, number]
62+
}
63+
64+
/** Get the quote and range from given attribute values */
65+
function getQuoteAndRange(
66+
attr:
67+
| AST.SvelteAttribute
68+
| AST.SvelteDirective
69+
| AST.SvelteSpecialDirective,
70+
valueTokens: NodeOrToken[],
71+
): QuoteAndRange | null {
72+
const valueFirstToken = valueTokens[0]
73+
const valueLastToken = valueTokens[valueTokens.length - 1]
74+
const eqToken = sourceCode.getTokenAfter(attr.key)
75+
if (
76+
!eqToken ||
77+
eqToken.value !== "=" ||
78+
valueFirstToken.range![0] < eqToken.range[1]
79+
) {
80+
// invalid
81+
return null
82+
}
83+
const beforeTokens = sourceCode.getTokensBetween(eqToken, valueFirstToken)
84+
if (beforeTokens.length === 0) {
85+
return {
86+
quote: "unquoted",
87+
range: [valueFirstToken.range![0], valueLastToken.range![1]],
88+
}
89+
} else if (
90+
beforeTokens.length > 1 ||
91+
(beforeTokens[0].value !== '"' && beforeTokens[0].value !== "'")
92+
) {
93+
// invalid
94+
return null
95+
}
96+
const beforeToken = beforeTokens[0]
97+
const afterToken = sourceCode.getTokenAfter(valueLastToken)
98+
if (!afterToken || afterToken.value !== beforeToken.value) {
99+
// invalid
100+
return null
101+
}
102+
103+
return {
104+
quote: beforeToken.value === '"' ? "double" : "single",
105+
range: [beforeToken.range[0], afterToken.range[1]],
106+
}
107+
}
108+
109+
/** Checks whether the given text can remove quotes in HTML. */
110+
function canBeUnquotedInHTML(text: string) {
111+
return !/[\s"'<=>`]/u.test(text)
112+
}
113+
114+
/** Verify quote */
115+
function verifyQuote(
116+
prefer: "double" | "single" | "unquoted",
117+
quoteAndRange: QuoteAndRange | null,
118+
) {
119+
if (!quoteAndRange) {
120+
// invalid
121+
return
122+
}
123+
if (quoteAndRange.quote === prefer) {
124+
// valid
125+
return
126+
}
127+
128+
let messageId: string
129+
let expectedQuote = prefer
130+
if (quoteAndRange.quote !== "unquoted") {
131+
if (expectedQuote === "unquoted") {
132+
messageId = "unexpectedEnclosed"
133+
} else {
134+
const contentText = sourceCode.text.slice(
135+
quoteAndRange.range[0] + 1,
136+
quoteAndRange.range[1] - 1,
137+
)
138+
const needEscape = contentText.includes(QUOTE_CHARS[expectedQuote])
139+
if (needEscape) {
140+
// avoid escape
141+
return
142+
}
143+
messageId = "expectedEnclosedBy"
144+
}
145+
} else {
146+
const contentText = sourceCode.text.slice(...quoteAndRange.range)
147+
const needEscapeDoubleQuote = contentText.includes('"')
148+
const needEscapeSingleQuote = contentText.includes("'")
149+
if (needEscapeDoubleQuote && needEscapeSingleQuote) {
150+
// avoid escape
151+
return
152+
}
153+
if (needEscapeDoubleQuote && expectedQuote === "double") {
154+
expectedQuote = "single"
155+
messageId = "expectedEnclosed"
156+
} else if (needEscapeSingleQuote && expectedQuote === "single") {
157+
expectedQuote = "double"
158+
messageId = "expectedEnclosed"
159+
} else {
160+
messageId = "expectedEnclosedBy"
161+
}
162+
}
163+
164+
context.report({
165+
loc: {
166+
start: sourceCode.getLocFromIndex(quoteAndRange.range[0]),
167+
end: sourceCode.getLocFromIndex(quoteAndRange.range[1]),
168+
},
169+
messageId,
170+
data: { kind: QUOTE_NAMES[expectedQuote] },
171+
*fix(fixer) {
172+
if (expectedQuote !== "unquoted") {
173+
yield fixer.insertTextBeforeRange(
174+
[quoteAndRange.range[0], quoteAndRange.range[0]],
175+
QUOTE_CHARS[expectedQuote],
176+
)
177+
}
178+
if (quoteAndRange.quote !== "unquoted") {
179+
yield fixer.removeRange([
180+
quoteAndRange.range[0],
181+
quoteAndRange.range[0] + 1,
182+
])
183+
yield fixer.removeRange([
184+
quoteAndRange.range[1] - 1,
185+
quoteAndRange.range[1],
186+
])
187+
}
188+
189+
if (expectedQuote !== "unquoted") {
190+
yield fixer.insertTextAfterRange(
191+
[quoteAndRange.range[1], quoteAndRange.range[1]],
192+
QUOTE_CHARS[expectedQuote],
193+
)
194+
}
195+
},
196+
})
197+
}
198+
199+
/** Verify for standard attribute */
200+
function verifyForValues(
201+
attr: AST.SvelteAttribute,
202+
valueNodes: AST.SvelteAttribute["value"],
203+
) {
204+
const quoteAndRange = getQuoteAndRange(attr, valueNodes)
205+
verifyQuote(preferQuote, quoteAndRange)
206+
}
207+
208+
/** Verify for dynamic attribute */
209+
function verifyForDynamicMustacheTag(
210+
attr: AST.SvelteAttribute,
211+
valueNode: AST.SvelteMustacheTag & {
212+
kind: "text"
213+
},
214+
) {
215+
const quoteAndRange = getQuoteAndRange(attr, [valueNode])
216+
const text = sourceCode.text.slice(...valueNode.range)
217+
verifyQuote(
218+
avoidInvalidUnquotedInHTML && !canBeUnquotedInHTML(text)
219+
? preferQuote
220+
: dynamicQuote,
221+
quoteAndRange,
222+
)
223+
}
224+
225+
/** Verify for directive value */
226+
function verifyForDirective(
227+
attr: AST.SvelteDirective | AST.SvelteSpecialDirective,
228+
valueNode: NonNullable<AST.SvelteDirective["expression"]>,
229+
) {
230+
const beforeToken = sourceCode.getTokenBefore(valueNode)
231+
const afterToken = sourceCode.getTokenAfter(valueNode)
232+
if (
233+
!beforeToken ||
234+
!afterToken ||
235+
isNotOpeningBraceToken(beforeToken) ||
236+
isNotClosingBraceToken(afterToken)
237+
) {
238+
return
239+
}
240+
const quoteAndRange = getQuoteAndRange(attr, [beforeToken, afterToken])
241+
const text = sourceCode.text.slice(
242+
beforeToken.range[0],
243+
afterToken.range[1],
244+
)
245+
verifyQuote(
246+
avoidInvalidUnquotedInHTML && !canBeUnquotedInHTML(text)
247+
? preferQuote
248+
: dynamicQuote,
249+
quoteAndRange,
250+
)
251+
}
252+
253+
return {
254+
SvelteAttribute(node) {
255+
if (
256+
node.value.length === 1 &&
257+
node.value[0].type === "SvelteMustacheTag"
258+
) {
259+
verifyForDynamicMustacheTag(node, node.value[0])
260+
} else if (node.value.length >= 1) {
261+
verifyForValues(node, node.value)
262+
}
263+
},
264+
"SvelteDirective, SvelteSpecialDirective"(
265+
node: AST.SvelteDirective | AST.SvelteSpecialDirective,
266+
) {
267+
if (node.expression == null) {
268+
return
269+
}
270+
if (
271+
node.key.range[0] <= node.expression.range![0] &&
272+
node.expression.range![1] <= node.key.range[1]
273+
) {
274+
// shorthand
275+
return
276+
}
277+
verifyForDirective(node, node.expression)
278+
},
279+
}
280+
},
281+
})

src/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,11 @@ export type RuleContext = {
128128
report(descriptor: ReportDescriptor): void
129129
}
130130

131-
type NodeOrToken = { type: string; loc?: AST.SourceLocation | null }
131+
export type NodeOrToken = {
132+
type: string
133+
loc?: AST.SourceLocation | null
134+
range?: [number, number]
135+
}
132136

133137
interface ReportDescriptorOptionsBase {
134138
data?: { [key: string]: string }

src/utils/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { RuleModule } from "../types"
22
import buttonHasType from "../rules/button-has-type"
33
import commentDirective from "../rules/comment-directive"
4+
import htmlQuotes from "../rules/html-quotes"
45
import indent from "../rules/indent"
56
import maxAttributesPerLine from "../rules/max-attributes-per-line"
67
import noAtDebugTags from "../rules/no-at-debug-tags"
@@ -16,6 +17,7 @@ import system from "../rules/system"
1617
export const rules = [
1718
buttonHasType,
1819
commentDirective,
20+
htmlQuotes,
1921
indent,
2022
maxAttributesPerLine,
2123
noAtDebugTags,

0 commit comments

Comments
 (0)