kensington-eslint-plugin 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ // Shared helpers used by the formatting rules.
2
+
3
+ // HTML boolean attributes per the WHATWG HTML spec. Kebab-case form is the
4
+ // attribute name; camelCase keys (e.g. `formNoValidate`) also match because the
5
+ // tag-call check looks at the key text after camel-to-kebab conversion.
6
+ export const HTML_BOOLEAN_ATTRS = new Set([
7
+ 'allowfullscreen',
8
+ 'async',
9
+ 'autofocus',
10
+ 'autoplay',
11
+ 'checked',
12
+ 'controls',
13
+ 'default',
14
+ 'defer',
15
+ 'disabled',
16
+ 'formnovalidate',
17
+ 'hidden',
18
+ 'inert',
19
+ 'ismap',
20
+ 'itemscope',
21
+ 'loop',
22
+ 'multiple',
23
+ 'muted',
24
+ 'nomodule',
25
+ 'novalidate',
26
+ 'open',
27
+ 'playsinline',
28
+ 'readonly',
29
+ 'required',
30
+ 'reversed',
31
+ 'selected',
32
+ 'shadowrootclonable',
33
+ 'shadowrootdelegatesfocus',
34
+ 'shadowrootserializable',
35
+ ]);
36
+
37
+ export function camelToKebab(str) {
38
+ return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase());
39
+ }
40
+
41
+ export function kebabToCamel(str) {
42
+ return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
43
+ }
44
+
45
+ // `name` may be a quoted-kebab string ('aria-label') or a camelCase identifier
46
+ // (ariaLabel). Returns the kebab-case form.
47
+ export function normalizeAttrName(name) {
48
+ return name.includes('-') ? name : camelToKebab(name);
49
+ }
50
+
51
+ // Returns true when an identifier name is a safe JS identifier.
52
+ const IDENT_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
53
+ export function isValidIdentifier(str) {
54
+ return IDENT_RE.test(str);
55
+ }
56
+
57
+ // Tag-method matcher: callee is `<obj>.<lowercaseMethod>(...)` where `<obj>` is
58
+ // in `objectNames` (default `['t']`, the documented Kensington convention).
59
+ // Returns the call's first ObjectExpression argument (the attributes object) or
60
+ // null when the call doesn't look like a tag call with attributes.
61
+ export function isTagCall(node, objectNames = ['t']) {
62
+ if (node.type !== 'CallExpression') { return false; }
63
+ if (node.callee.type !== 'MemberExpression') { return false; }
64
+ if (node.callee.object.type !== 'Identifier') { return false; }
65
+ if (!objectNames.includes(node.callee.object.name)) { return false; }
66
+ if (node.callee.property.type !== 'Identifier') { return false; }
67
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(node.callee.property.name)) { return false; }
68
+ return true;
69
+ }
70
+
71
+ export function getTagAttrsObject(node, objectNames) {
72
+ if (!isTagCall(node, objectNames)) { return null; }
73
+ const first = node.arguments[0];
74
+ if (!first || first.type !== 'ObjectExpression') { return null; }
75
+ return first;
76
+ }
77
+
78
+ // Standard rule schema entry for the `objectNames` option.
79
+ export const objectNamesSchema = {
80
+ type: 'array',
81
+ items: { type: 'string' },
82
+ uniqueItems: true,
83
+ };
84
+
85
+ // Resolve `objectNames` from per-rule options first, then from plugin-level
86
+ // settings (`settings.kensington.objectNames`). Returns undefined when neither
87
+ // is set, letting the helpers fall back to the built-in default of `['t']`.
88
+ export function getObjectNames(context) {
89
+ return context.options[0]?.objectNames ?? context.settings?.kensington?.objectNames;
90
+ }
91
+
92
+ // Returns the property key as a string, or null if computed/unrecognised.
93
+ export function getPropertyKey(prop) {
94
+ if (prop.type !== 'Property') { return null; }
95
+ if (prop.computed) { return null; }
96
+ if (prop.key.type === 'Identifier') { return prop.key.name; }
97
+ if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') { return prop.key.value; }
98
+ return null;
99
+ }
100
+
101
+ // Split a CSS declaration string ("color: red; z-index: 2") into entries.
102
+ // Respects quoted values so semicolons inside strings don't split.
103
+ export function splitCssDeclarations(css) {
104
+ const result = [];
105
+ let current = '';
106
+ let inStr = null;
107
+ for (const c of css) {
108
+ if (inStr) {
109
+ current += c;
110
+ if (c === inStr) { inStr = null; }
111
+ } else if (c === '"' || c === "'") {
112
+ inStr = c;
113
+ current += c;
114
+ } else if (c === ';') {
115
+ result.push(current);
116
+ current = '';
117
+ } else {
118
+ current += c;
119
+ }
120
+ }
121
+ if (current.trim()) { result.push(current); }
122
+ return result;
123
+ }
124
+
125
+ // Parse a CSS string into { property, value } entries. Returns null if any
126
+ // declaration is malformed.
127
+ export function parseStyleString(css) {
128
+ const entries = [];
129
+ for (const decl of splitCssDeclarations(css)) {
130
+ const i = decl.indexOf(':');
131
+ if (i === -1) { continue; }
132
+ const prop = decl.slice(0, i).trim();
133
+ const val = decl.slice(i + 1).trim();
134
+ if (!prop || !val) { continue; }
135
+ entries.push({ property: prop, value: val });
136
+ }
137
+ return entries.length ? entries : null;
138
+ }
@@ -0,0 +1,102 @@
1
+ import { getTagAttrsObject, getObjectNames, objectNamesSchema } from './_utils.js';
2
+
3
+ // An attributes object must be in one of two canonical forms:
4
+ // inline: { a: 1, b: 2 } — every token on the same line.
5
+ // stacked: { — `{` ends its line,
6
+ // a: 1, each property on its own line,
7
+ // b: 2, `}` begins its line.
8
+ // }
9
+ // Any mixed form (open brace adjacent to first prop in a multi-line body, close
10
+ // brace adjacent to last prop, or two props sharing a line) is reported.
11
+
12
+ export default {
13
+ meta: {
14
+ type: 'layout',
15
+ fixable: 'whitespace',
16
+ docs: {
17
+ description: 'attributes object must be inline or in canonical stacked form',
18
+ },
19
+ schema: [{
20
+ type: 'object',
21
+ properties: { objectNames: objectNamesSchema },
22
+ additionalProperties: false,
23
+ }],
24
+ messages: {
25
+ mixedLayout: 'Attribute properties must all be on the same line or each on its own line.',
26
+ openBraceMustEndLine: 'Opening brace of a multi-line attributes object must end its line.',
27
+ closeBraceMustStartLine: 'Closing brace of a multi-line attributes object must start its line.',
28
+ },
29
+ },
30
+
31
+ create(context) {
32
+ const sourceCode = context.sourceCode;
33
+ const objectNames = getObjectNames(context);
34
+
35
+ function leadingIndent(node) {
36
+ const line = sourceCode.lines[node.loc.start.line - 1];
37
+ const match = line.match(/^(\s*)/);
38
+ return match ? match[1] : '';
39
+ }
40
+
41
+ function buildStackedText(attrs, baseIndent) {
42
+ const propIndent = `${baseIndent} `;
43
+ const props = attrs.properties.map(p => sourceCode.getText(p));
44
+ return `{\n${props.map(t => `${propIndent}${t},`).join('\n')}\n${baseIndent}}`;
45
+ }
46
+
47
+ function hasInteriorComments(attrs) {
48
+ return sourceCode.getCommentsInside(attrs).length > 0;
49
+ }
50
+
51
+ return {
52
+ CallExpression(node) {
53
+ const attrs = getTagAttrsObject(node, objectNames);
54
+ if (!attrs) { return; }
55
+ if (attrs.properties.length < 2) { return; }
56
+
57
+ const props = attrs.properties;
58
+ const openLine = attrs.loc.start.line;
59
+ const closeLine = attrs.loc.end.line;
60
+ const first = props[0];
61
+ const last = props[props.length - 1];
62
+
63
+ const allInline =
64
+ openLine === closeLine &&
65
+ props.every(p => p.loc.start.line === openLine && p.loc.end.line === openLine);
66
+
67
+ if (allInline) { return; }
68
+
69
+ const openBraceOk = first.loc.start.line > openLine;
70
+ const closeBraceOk = last.loc.end.line < closeLine;
71
+
72
+ let propsLayoutOk = true;
73
+ for (let i = 1; i < props.length; i++) {
74
+ if (props[i].loc.start.line <= props[i - 1].loc.end.line) {
75
+ propsLayoutOk = false;
76
+ break;
77
+ }
78
+ }
79
+
80
+ if (openBraceOk && closeBraceOk && propsLayoutOk) { return; }
81
+
82
+ const baseIndent = leadingIndent(node);
83
+ const canFix = !hasInteriorComments(attrs);
84
+ const fix = canFix
85
+ ? (fixer => fixer.replaceText(attrs, buildStackedText(attrs, baseIndent)))
86
+ : null;
87
+
88
+ if (!propsLayoutOk) {
89
+ context.report({ node: attrs, messageId: 'mixedLayout', fix });
90
+ return;
91
+ }
92
+ if (!openBraceOk) {
93
+ context.report({ node: attrs, messageId: 'openBraceMustEndLine', fix });
94
+ return;
95
+ }
96
+ if (!closeBraceOk) {
97
+ context.report({ node: attrs, messageId: 'closeBraceMustStartLine', fix });
98
+ }
99
+ },
100
+ };
101
+ },
102
+ };
@@ -0,0 +1,82 @@
1
+ import { getTagAttrsObject, getObjectNames, objectNamesSchema } from './_utils.js';
2
+
3
+ // The attributes object hugs the call on both ends:
4
+ // - the opening `{` sits on the same line as the call's `(`, and
5
+ // - the closing `}` sits on the same line as the content (or its `[`) when
6
+ // there is a content arg, or on the same line as the call's `)` when there
7
+ // is not.
8
+
9
+ export default {
10
+ meta: {
11
+ type: 'layout',
12
+ fixable: 'whitespace',
13
+ docs: {
14
+ description: 'attributes object must hug the tag call on both ends',
15
+ },
16
+ schema: [{
17
+ type: 'object',
18
+ properties: { objectNames: objectNamesSchema },
19
+ additionalProperties: false,
20
+ }],
21
+ messages: {
22
+ attrsMustStartOnCallLine: 'Attributes object must start on the same line as the tag call.',
23
+ attrsMustEndOnContentLine: 'Closing brace of attributes object must be on the same line as the content.',
24
+ attrsMustEndOnCloseParenLine: 'Closing brace of attributes object must be on the same line as the closing parenthesis.',
25
+ },
26
+ },
27
+
28
+ create(context) {
29
+ const sourceCode = context.sourceCode;
30
+ const objectNames = getObjectNames(context);
31
+
32
+ return {
33
+ CallExpression(node) {
34
+ const attrs = getTagAttrsObject(node, objectNames);
35
+ if (!attrs) { return; }
36
+
37
+ // Open-brace check: `{` on the same line as `(`.
38
+ const openParen = sourceCode.getTokenAfter(node.callee);
39
+ if (openParen && attrs.loc.start.line > openParen.loc.end.line) {
40
+ const between = sourceCode.text.slice(openParen.range[1], attrs.range[0]);
41
+ const hasComment = /\/\/|\/\*/.test(between);
42
+ context.report({
43
+ node: attrs,
44
+ messageId: 'attrsMustStartOnCallLine',
45
+ fix: hasComment
46
+ ? null
47
+ : (fixer => fixer.replaceTextRange([openParen.range[1], attrs.range[0]], '')),
48
+ });
49
+ }
50
+
51
+ // Close-brace check: `}` on the same line as the next anchor.
52
+ if (node.arguments.length === 2) {
53
+ const content = node.arguments[1];
54
+ if (attrs.loc.end.line < content.loc.start.line) {
55
+ const between = sourceCode.text.slice(attrs.range[1], content.range[0]);
56
+ const hasComment = /\/\/|\/\*/.test(between);
57
+ context.report({
58
+ node: attrs,
59
+ messageId: 'attrsMustEndOnContentLine',
60
+ fix: hasComment
61
+ ? null
62
+ : (fixer => fixer.replaceTextRange([attrs.range[1], content.range[0]], ', ')),
63
+ });
64
+ }
65
+ } else if (node.arguments.length === 1) {
66
+ const closeParen = sourceCode.getLastToken(node);
67
+ if (closeParen && closeParen.value === ')' && attrs.loc.end.line < closeParen.loc.start.line) {
68
+ const between = sourceCode.text.slice(attrs.range[1], closeParen.range[0]);
69
+ const hasComment = /\/\/|\/\*/.test(between);
70
+ context.report({
71
+ node: attrs,
72
+ messageId: 'attrsMustEndOnCloseParenLine',
73
+ fix: hasComment
74
+ ? null
75
+ : (fixer => fixer.replaceTextRange([attrs.range[1], closeParen.range[0]], '')),
76
+ });
77
+ }
78
+ }
79
+ },
80
+ };
81
+ },
82
+ };
@@ -0,0 +1,109 @@
1
+ import { isTagCall, getObjectNames, objectNamesSchema } from './_utils.js';
2
+
3
+ // Tag content (or its opening `[`) must begin on the same line as the anchor
4
+ // before it — the attrs object's `}` for two-arg calls, or the call's `(` for
5
+ // one-arg calls. Content must also end on the same line as the call's `)`.
6
+
7
+ export default {
8
+ meta: {
9
+ type: 'layout',
10
+ fixable: 'whitespace',
11
+ docs: {
12
+ description: 'tag content must start on the call/attrs line and end on the closing-paren line',
13
+ },
14
+ schema: [{
15
+ type: 'object',
16
+ properties: { objectNames: objectNamesSchema },
17
+ additionalProperties: false,
18
+ }],
19
+ messages: {
20
+ contentMustStartOnAnchorLine: 'Tag content must start on the same line as {{anchor}}.',
21
+ contentMustEndOnClosingParenLine: 'Tag content must end on the same line as the closing parenthesis.',
22
+ },
23
+ },
24
+
25
+ create(context) {
26
+ const sourceCode = context.sourceCode;
27
+ const objectNames = getObjectNames(context);
28
+
29
+ function getContentArg(node) {
30
+ if (node.arguments.length === 1) {
31
+ const arg = node.arguments[0];
32
+ if (arg.type === 'ObjectExpression') { return null; }
33
+ return arg;
34
+ }
35
+ if (node.arguments.length === 2) {
36
+ if (node.arguments[0].type !== 'ObjectExpression') { return null; }
37
+ return node.arguments[1];
38
+ }
39
+ return null;
40
+ }
41
+
42
+ return {
43
+ CallExpression(node) {
44
+ if (!isTagCall(node, objectNames)) { return; }
45
+
46
+ const content = getContentArg(node);
47
+ if (!content) { return; }
48
+ if (content.type === 'SpreadElement') { return; }
49
+
50
+ const closeParen = sourceCode.getLastToken(node);
51
+ if (!closeParen || closeParen.value !== ')') { return; }
52
+
53
+ // Start check: content must begin on the anchor's line.
54
+ if (node.arguments.length === 2) {
55
+ const attrs = node.arguments[0];
56
+ if (content.loc.start.line > attrs.loc.end.line) {
57
+ const commaToken = sourceCode.getTokenAfter(attrs);
58
+ if (commaToken && commaToken.value === ',') {
59
+ const between = sourceCode.text.slice(commaToken.range[1], content.range[0]);
60
+ const hasComment = /\/\/|\/\*/.test(between);
61
+ context.report({
62
+ node: content,
63
+ messageId: 'contentMustStartOnAnchorLine',
64
+ data: { anchor: "the attributes object's closing brace" },
65
+ fix: hasComment
66
+ ? null
67
+ : (fixer => fixer.replaceTextRange([commaToken.range[1], content.range[0]], ' ')),
68
+ });
69
+ }
70
+ }
71
+ } else {
72
+ const openParen = sourceCode.getTokenAfter(node.callee);
73
+ if (openParen && content.loc.start.line > openParen.loc.end.line) {
74
+ const between = sourceCode.text.slice(openParen.range[1], content.range[0]);
75
+ const hasComment = /\/\/|\/\*/.test(between);
76
+ context.report({
77
+ node: content,
78
+ messageId: 'contentMustStartOnAnchorLine',
79
+ data: { anchor: "the tag method call's opening parenthesis" },
80
+ fix: hasComment
81
+ ? null
82
+ : (fixer => fixer.replaceTextRange([openParen.range[1], content.range[0]], '')),
83
+ });
84
+ }
85
+ }
86
+
87
+ // End check: content must end on the closing paren's line. If there's a
88
+ // trailing comma right after content, treat that as part of the end.
89
+ const tokenAfterContent = sourceCode.getTokenAfter(content);
90
+ const endBoundary = tokenAfterContent && tokenAfterContent.value === ',' && tokenAfterContent.range[0] < closeParen.range[0]
91
+ ? tokenAfterContent
92
+ : content;
93
+ const endBoundaryLine = endBoundary === content ? content.loc.end.line : endBoundary.loc.end.line;
94
+
95
+ if (endBoundaryLine < closeParen.loc.start.line) {
96
+ const between = sourceCode.text.slice(endBoundary.range[1], closeParen.range[0]);
97
+ const hasComment = /\/\/|\/\*/.test(between);
98
+ context.report({
99
+ node: content,
100
+ messageId: 'contentMustEndOnClosingParenLine',
101
+ fix: hasComment
102
+ ? null
103
+ : (fixer => fixer.replaceTextRange([endBoundary.range[1], closeParen.range[0]], '')),
104
+ });
105
+ }
106
+ },
107
+ };
108
+ },
109
+ };
@@ -1,14 +1,16 @@
1
- // Reports .set() calls inside a .transform() callback.
2
- // Stops searching when a nested effect() or computed() is reached (separate reactive context).
1
+ // Reports .set() calls inside a derivation — a computed() callback or a
2
+ // .transform() callback. Derivations must be pure; writing inside them causes
3
+ // a write during a read pass. Stops searching when a nested effect() or
4
+ // non-derivation function boundary is reached.
3
5
  export default {
4
6
  meta: {
5
7
  type: 'problem',
6
8
  docs: {
7
- description: 'disallow .set() inside a .transform() callback',
9
+ description: 'disallow .set() inside a computed() body or .transform() callback',
8
10
  },
9
11
  messages: {
10
- noSetInTransform:
11
- '.set() called inside a .transform() callback. Transform functions must be pure derivations. ' +
12
+ noSetInDerivation:
13
+ '.set() called inside a {{kind}}. Derivations must be pure. ' +
12
14
  'Move the write into a separate effect() instead.',
13
15
  },
14
16
  },
@@ -16,7 +18,8 @@ export default {
16
18
  create(context) {
17
19
  const computedNames = new Set();
18
20
  const effectNames = new Set();
19
- // Each entry is 'transform', 'computed', 'effect', or 'other' — innermost frame is last.
21
+ // Each entry is 'computed', 'transform', 'effect', 'handler', or 'other' —
22
+ // innermost frame is last.
20
23
  const fnStack = [];
21
24
 
22
25
  return {
@@ -72,11 +75,16 @@ export default {
72
75
  ) { return; }
73
76
 
74
77
  for (let i = fnStack.length - 1; i >= 0; i--) {
75
- if (fnStack[i] === 'transform') {
76
- context.report({ node, messageId: 'noSetInTransform' });
78
+ const frame = fnStack[i];
79
+ if (frame === 'computed') {
80
+ context.report({ node, messageId: 'noSetInDerivation', data: { kind: 'computed() body' } });
77
81
  return;
78
82
  }
79
- if (fnStack[i] === 'effect' || fnStack[i] === 'computed' || fnStack[i] === 'handler') { return; }
83
+ if (frame === 'transform') {
84
+ context.report({ node, messageId: 'noSetInDerivation', data: { kind: '.transform() callback' } });
85
+ return;
86
+ }
87
+ if (frame === 'effect' || frame === 'handler') { return; }
80
88
  }
81
89
  },
82
90
  };
@@ -0,0 +1,68 @@
1
+ // Reports .set() on a variable that holds a derived signal — one produced by
2
+ // computed() (imported from kensington) or by .transform() on any signal.
3
+ // Derived signals are read-only; kensington throws at runtime, but this catches
4
+ // it statically.
5
+ export default {
6
+ meta: {
7
+ type: 'problem',
8
+ docs: {
9
+ description: 'disallow .set() on a derived (computed or transform) signal',
10
+ },
11
+ messages: {
12
+ noSetOnDerived:
13
+ "'{{name}}' is a {{kind}} signal and cannot be written with .set(). " +
14
+ 'Use signal() for writable state.',
15
+ },
16
+ },
17
+
18
+ create(context) {
19
+ const computedNames = new Set();
20
+ // name -> 'computed' | 'transform'
21
+ const derivedBindings = new Map();
22
+
23
+ return {
24
+ ImportDeclaration(node) {
25
+ if (node.source.value !== 'kensington') { return; }
26
+ for (const spec of node.specifiers) {
27
+ if (spec.type !== 'ImportSpecifier') { continue; }
28
+ if (spec.imported.name === 'computed') { computedNames.add(spec.local.name); }
29
+ }
30
+ },
31
+
32
+ VariableDeclarator(node) {
33
+ if (node.id.type !== 'Identifier' || !node.init) { return; }
34
+ const init = node.init;
35
+ if (init.type !== 'CallExpression') { return; }
36
+
37
+ if (init.callee.type === 'Identifier' && computedNames.has(init.callee.name)) {
38
+ derivedBindings.set(node.id.name, 'computed');
39
+ return;
40
+ }
41
+ if (
42
+ init.callee.type === 'MemberExpression' &&
43
+ init.callee.property.type === 'Identifier' &&
44
+ init.callee.property.name === 'transform'
45
+ ) {
46
+ derivedBindings.set(node.id.name, 'transform');
47
+ }
48
+ },
49
+
50
+ CallExpression(node) {
51
+ if (
52
+ node.callee.type !== 'MemberExpression' ||
53
+ node.callee.object.type !== 'Identifier' ||
54
+ node.callee.property.type !== 'Identifier' ||
55
+ node.callee.property.name !== 'set' ||
56
+ node.arguments.length !== 1
57
+ ) { return; }
58
+
59
+ const name = node.callee.object.name;
60
+ const kind = derivedBindings.get(name);
61
+ if (kind) {
62
+ const label = kind === 'computed' ? 'computed' : 'transform-derived';
63
+ context.report({ node, messageId: 'noSetOnDerived', data: { name, kind: label } });
64
+ }
65
+ },
66
+ };
67
+ },
68
+ };
@@ -0,0 +1,83 @@
1
+ // Tag content on a separate line from the call's opening paren must be wrapped
2
+ // in an array, even when it's the only item. Mirrors what html-to-kensington
3
+ // emits when a tag's content can't fit on a single line.
4
+
5
+ import { isTagCall, getObjectNames, objectNamesSchema } from './_utils.js';
6
+
7
+ export default {
8
+ meta: {
9
+ type: 'layout',
10
+ fixable: 'code',
11
+ docs: {
12
+ description: 'require array brackets around tag content that spans multiple lines',
13
+ },
14
+ schema: [{
15
+ type: 'object',
16
+ properties: { objectNames: objectNamesSchema },
17
+ additionalProperties: false,
18
+ }],
19
+ messages: {
20
+ wrapInArray: 'Tag content on a separate line must be wrapped in an array.',
21
+ },
22
+ },
23
+
24
+ create(context) {
25
+ const sourceCode = context.sourceCode;
26
+ const objectNames = getObjectNames(context);
27
+
28
+ function getContentArg(node) {
29
+ if (node.arguments.length === 1) {
30
+ const arg = node.arguments[0];
31
+ if (arg.type === 'ObjectExpression') { return null; } // attrs only
32
+ return arg;
33
+ }
34
+ if (node.arguments.length === 2) {
35
+ if (node.arguments[0].type !== 'ObjectExpression') { return null; }
36
+ return node.arguments[1];
37
+ }
38
+ return null;
39
+ }
40
+
41
+ return {
42
+ CallExpression(node) {
43
+ if (!isTagCall(node, objectNames)) { return; }
44
+
45
+ const content = getContentArg(node);
46
+ if (!content) { return; }
47
+ if (content.type === 'ArrayExpression') { return; }
48
+ if (content.type === 'SpreadElement') { return; }
49
+
50
+ // The "opening line" is where the open paren sits — same line as the
51
+ // callee's last token. Content on that line stays bare.
52
+ const openParenLine = node.callee.loc.end.line;
53
+ if (content.loc.start.line <= openParenLine) { return; }
54
+
55
+ context.report({
56
+ node: content,
57
+ messageId: 'wrapInArray',
58
+ *fix(fixer) {
59
+ const closeParen = sourceCode.getLastToken(node);
60
+ const tokenAfterContent = sourceCode.getTokenAfter(content);
61
+ const hasTrailingComma = tokenAfterContent
62
+ && tokenAfterContent.value === ','
63
+ && tokenAfterContent.range[0] < closeParen.range[0];
64
+
65
+ if (node.arguments.length === 2) {
66
+ const commaToken = sourceCode.getTokenAfter(node.arguments[0]);
67
+ yield fixer.insertTextAfter(commaToken, ' [');
68
+ } else {
69
+ const openParen = sourceCode.getTokenAfter(node.callee);
70
+ yield fixer.insertTextAfter(openParen, '[');
71
+ }
72
+
73
+ if (!hasTrailingComma) {
74
+ yield fixer.insertTextAfter(content, ',');
75
+ }
76
+
77
+ yield fixer.insertTextBefore(closeParen, ']');
78
+ },
79
+ });
80
+ },
81
+ };
82
+ },
83
+ };
@@ -0,0 +1,51 @@
1
+ import { HTML_BOOLEAN_ATTRS, getTagAttrsObject, getPropertyKey, normalizeAttrName, getObjectNames, objectNamesSchema } from './_utils.js';
2
+
3
+ export default {
4
+ meta: {
5
+ type: 'suggestion',
6
+ fixable: 'code',
7
+ docs: {
8
+ description: 'prefer `true` over an empty string for boolean HTML attributes',
9
+ },
10
+ schema: [{
11
+ type: 'object',
12
+ properties: {
13
+ extraBooleanAttrs: { type: 'array', items: { type: 'string' } },
14
+ objectNames: objectNamesSchema,
15
+ },
16
+ additionalProperties: false,
17
+ }],
18
+ messages: {
19
+ preferTrue: 'Boolean attribute `{{name}}` should be `true`, not an empty string.',
20
+ },
21
+ },
22
+
23
+ create(context) {
24
+ const opts = context.options[0] ?? {};
25
+ const extras = new Set((opts.extraBooleanAttrs ?? []).map(s => s.toLowerCase()));
26
+ const booleanAttrs = new Set([...HTML_BOOLEAN_ATTRS, ...extras]);
27
+ const objectNames = getObjectNames(context);
28
+
29
+ return {
30
+ CallExpression(node) {
31
+ const attrs = getTagAttrsObject(node, objectNames);
32
+ if (!attrs) { return; }
33
+
34
+ for (const prop of attrs.properties) {
35
+ const key = getPropertyKey(prop);
36
+ if (!key) { continue; }
37
+ const kebab = normalizeAttrName(key).toLowerCase();
38
+ if (!booleanAttrs.has(kebab)) { continue; }
39
+ if (prop.value.type !== 'Literal') { continue; }
40
+ if (prop.value.value !== '') { continue; }
41
+ context.report({
42
+ node: prop.value,
43
+ messageId: 'preferTrue',
44
+ data: { name: key },
45
+ fix: fixer => fixer.replaceText(prop.value, 'true'),
46
+ });
47
+ }
48
+ },
49
+ };
50
+ },
51
+ };