Skip to content

Commit b90c04f

Browse files
danilsomsikovDevtools-frontend LUCI CQ
authored andcommitted
Eslint rule for identifying and templatizing manually constructed DOM
This is not yet enabled and will be further expanded to handle more imperative API and variables Bug: 400353541 Change-Id: Ie1d514bc2a1813a5cadf98b1a46579c386f22203 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6346607 Auto-Submit: Danil Somsikov <[email protected]> Commit-Queue: Danil Somsikov <[email protected]> Reviewed-by: Philip Pfaffe <[email protected]> Reviewed-by: Nikolay Vitkov <[email protected]>
1 parent 2cc33ca commit b90c04f

File tree

2 files changed

+340
-0
lines changed

2 files changed

+340
-0
lines changed
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
/**
5+
* @fileoverview Rule to identify and templatize manually constructed DOM.
6+
*
7+
* To check types, run
8+
* $ npx tsc --noEmit --allowJS --checkJS --downlevelIteration scripts/eslint_rules/lib/no-imperative-dom-api.js
9+
*/
10+
'use strict';
11+
12+
function isIdentifier(node, name) {
13+
return node.type === 'Identifier' && (Array.isArray(name) ? name.includes(node.name) : node.name === name);
14+
}
15+
16+
function getEnclosingExpression(node) {
17+
while (node.parent) {
18+
if (node.parent.type === 'BlockStatement') {
19+
return node;
20+
}
21+
node = node.parent;
22+
}
23+
return null;
24+
}
25+
26+
function getEnclosingClassDeclaration(node) {
27+
let parent = node.parent;
28+
while (parent && parent.type !== 'ClassDeclaration') {
29+
parent = parent.parent;
30+
}
31+
return parent;
32+
}
33+
34+
function attributeValue(outputString) {
35+
if (outputString.startsWith('${') && outputString.endsWith('}')) {
36+
return outputString;
37+
}
38+
return '"' + outputString + '"';
39+
}
40+
41+
/** @typedef {import('eslint').Rule.Node} Node */
42+
/** @typedef {import('eslint').AST.SourceLocation} SourceLocation */
43+
/** @typedef {import('eslint').Scope.Variable} Variable */
44+
/** @typedef {import('eslint').Scope.Reference} Reference*/
45+
/** @typedef {{node: Node, processed?: boolean}} DomFragmentReference*/
46+
47+
class DomFragment {
48+
/** @type {string|undefined} */ tagName;
49+
/** @type {Node[]} */ classList = [];
50+
/** @type {{key: string, value: Node}[]} */ attributes = [];
51+
/** @type {Node} */ textContent;
52+
/** @type {DomFragment[]} */ children = [];
53+
/** @type {DomFragment|undefined} */ parent;
54+
/** @type {string|undefined} */ expression;
55+
/** @type {Node|undefined} */ replacementLocation;
56+
/** @type {DomFragmentReference[]} */ references = [];
57+
58+
/** @return {string[]} */
59+
toTemplateLiteral(sourceCode, indent = 4) {
60+
if (this.expression && !this.tagName) {
61+
return [`\n${' '.repeat(indent)}`, '${', this.expression, '}'];
62+
}
63+
function toOutputString(node) {
64+
if (node.type === 'Literal') {
65+
return node.value;
66+
}
67+
const text = sourceCode.getText(node);
68+
if (node.type === 'TemplateLiteral') {
69+
return text.substr(1, text.length - 2);
70+
}
71+
return '${' + text + '}';
72+
}
73+
74+
/** @type {string[]} */ const components = [];
75+
const MAX_LINE_LENGTH = 100;
76+
components.push(`\n${' '.repeat(indent)}`);
77+
let lineLength = indent;
78+
79+
function appendExpression(expression) {
80+
if (lineLength + expression.length + 1 > MAX_LINE_LENGTH) {
81+
components.push(`\n${' '.repeat(indent + 4)}`);
82+
lineLength = expression.length + indent + 4;
83+
} else {
84+
components.push(' ');
85+
lineLength += expression.length + 1;
86+
}
87+
components.push(expression);
88+
}
89+
90+
if (this.tagName) {
91+
components.push('<', this.tagName);
92+
lineLength += this.tagName.length + 1;
93+
}
94+
if (this.classList.length) {
95+
appendExpression(`class="${this.classList.map(toOutputString).join(' ')}"`);
96+
}
97+
for (const attribute of this.attributes || []) {
98+
appendExpression(`${attribute.key}=${attributeValue(toOutputString(attribute.value))}`);
99+
}
100+
if (lineLength > MAX_LINE_LENGTH) {
101+
components.push(`\n${' '.repeat(indent)}`);
102+
}
103+
components.push('>');
104+
if (this.textContent) {
105+
components.push(toOutputString(this.textContent));
106+
} else {
107+
for (const child of this.children || []) {
108+
components.push(...child.toTemplateLiteral(sourceCode, indent + 2));
109+
}
110+
components.push(`\n${' '.repeat(indent)}`);
111+
}
112+
components.push('</', this.tagName, '>');
113+
return components;
114+
}
115+
}
116+
117+
module.exports = {
118+
meta : {
119+
type : 'problem',
120+
docs : {
121+
description : 'Prefer template literals over imperative DOM API calls',
122+
category : 'Possible Errors',
123+
},
124+
messages: {
125+
preferTemplateLiterals: 'Prefer template literals over imperative DOM API calls',
126+
},
127+
fixable : 'code',
128+
schema : [] // no options
129+
},
130+
create : function(context) {
131+
/** @type {Array<DomFragment>} */
132+
const queue = [];
133+
const sourceCode = context.getSourceCode();
134+
135+
/** @type {Map<string, DomFragment>} */
136+
const domFragments = new Map();
137+
138+
/**
139+
* @param {Node} node
140+
* @return {DomFragment}
141+
*/
142+
function getOrCreateDomFragment(node) {
143+
const key = sourceCode.getText(node);
144+
145+
let result = domFragments.get(key);
146+
if (!result) {
147+
result = new DomFragment();
148+
queue.push(result);
149+
domFragments.set(key, result);
150+
result.expression = sourceCode.getText(node);
151+
const classDeclaration = getEnclosingClassDeclaration(node);
152+
if (classDeclaration) {
153+
result.replacementLocation = classDeclaration;
154+
}
155+
}
156+
result.references.push({node});
157+
return result;
158+
}
159+
160+
/**
161+
* @param {DomFragmentReference} reference
162+
* @param {DomFragment} domFragment
163+
*/
164+
function processReference(reference, domFragment) {
165+
const parent = reference.node.parent;
166+
const isAccessed = parent.type === 'MemberExpression' && parent.object === reference.node;
167+
const property = isAccessed ? parent.property : null;
168+
const grandParent = parent.parent;
169+
const isPropertyAssignment =
170+
isAccessed && grandParent.type === 'AssignmentExpression' && grandParent.left === parent;
171+
const propertyValue = isPropertyAssignment ? /** @type {Node} */(grandParent.right) : null;
172+
const isMethodCall = isAccessed && grandParent.type === 'CallExpression' && grandParent.callee === parent;
173+
const firstArg = isMethodCall ? /** @type {Node} */(grandParent.arguments[0]) : null;
174+
const secondArg = isMethodCall ? /** @type {Node} */(grandParent.arguments[1]) : null;
175+
176+
reference.processed = true;
177+
if (isPropertyAssignment && isIdentifier(property, 'className')) {
178+
domFragment.classList.push(propertyValue);
179+
} else if (isPropertyAssignment && isIdentifier(property, 'textContent')) {
180+
domFragment.textContent = propertyValue;
181+
} else if (isMethodCall && isIdentifier(property, 'setAttribute')) {
182+
const attribute = firstArg;
183+
const value = secondArg;
184+
if (attribute.type === 'Literal' && value.type !== 'SpreadElement') {
185+
domFragment.attributes.push({
186+
key: attribute.value.toString(),
187+
value,
188+
});
189+
}
190+
} else if (isMethodCall && isIdentifier(property, 'appendChild')) {
191+
const childFragment = getOrCreateDomFragment(firstArg);
192+
childFragment.parent = domFragment;
193+
domFragment.children.push(childFragment);
194+
} else {
195+
reference.processed = false;
196+
}
197+
}
198+
199+
function maybeReportDomFragment(domFragment, key) {
200+
if (!domFragment.replacementLocation || domFragment.parent) {
201+
return;
202+
}
203+
context.report({
204+
node: domFragment.replacementLocation,
205+
messageId: 'preferTemplateLiterals',
206+
fix(fixer) {
207+
let replacementLocation = /** @type {Node} */(domFragment.replacementLocation);
208+
if (replacementLocation.parent.type === 'ExportNamedDeclaration') {
209+
replacementLocation = replacementLocation.parent;
210+
}
211+
const template = domFragment.toTemplateLiteral(sourceCode).join('');
212+
const text = `
213+
export const DEFAULT_VIEW = (input, _output, target) => {
214+
render(html\`${template}\`,
215+
target, {host: input});
216+
};
217+
218+
`;
219+
return [
220+
fixer.insertTextBefore(replacementLocation, text),
221+
...domFragment.references.map(r => getEnclosingExpression(r.node)).filter(Boolean).map(r => {
222+
const range = r.range;
223+
while ([' ', '\n'].includes(sourceCode.text[range[0] - 1])) {
224+
range[0]--;
225+
}
226+
return fixer.removeRange(range);
227+
}),
228+
];
229+
}
230+
});
231+
}
232+
233+
return {
234+
MemberExpression(node) {
235+
if (node.object.type === 'ThisExpression' && isIdentifier(node.property, 'contentElement')) {
236+
const domFragment = getOrCreateDomFragment(node);
237+
domFragment.tagName = 'div';
238+
}
239+
if (isIdentifier(node.object, 'document') && isIdentifier(node.property, 'createElement')
240+
&& node.parent.type === 'CallExpression' && node.parent.callee === node) {
241+
const domFragment = getOrCreateDomFragment(node.parent);
242+
if (node.parent.arguments.length >= 1 && node.parent.arguments[0].type === 'Literal') {
243+
domFragment.tagName = node.parent.arguments[0].value;
244+
}
245+
}
246+
},
247+
'Program:exit'() {
248+
while (queue.length) {
249+
const domFragment = queue.pop();
250+
for (const reference of domFragment.references) {
251+
processReference(reference, domFragment);
252+
}
253+
domFragment.references = domFragment.references.filter(r => r.processed);
254+
}
255+
256+
for (const [key, domFragment] of domFragments.entries()) {
257+
maybeReportDomFragment(domFragment, key);
258+
}
259+
domFragments.clear();
260+
}
261+
};
262+
}
263+
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
'use strict';
5+
const rule = require('../lib/no-imperative-dom-api.js');
6+
7+
const {RuleTester} = require('./utils/utils.js');
8+
9+
new RuleTester().run('no-imperative-dom-api', rule, {
10+
valid: [
11+
{
12+
filename: 'front_end/ui/components/component/file.ts',
13+
code: `class SomeWidget extends UI.Widget.Widget {
14+
constructor() {
15+
super();
16+
this.element.className = 'some-class';
17+
}
18+
}`,
19+
},
20+
],
21+
22+
invalid: [
23+
{
24+
filename: 'front_end/ui/components/component/file.ts',
25+
code: `
26+
class SomeWidget extends UI.Widget.Widget {
27+
constructor() {
28+
super();
29+
this.contentElement.appendChild(document.createElement('div'));
30+
}
31+
}`,
32+
output: `
33+
34+
export const DEFAULT_VIEW = (input, _output, target) => {
35+
render(html\`
36+
<div>
37+
<div>
38+
</div>
39+
</div>\`,
40+
target, {host: input});
41+
};
42+
43+
class SomeWidget extends UI.Widget.Widget {
44+
constructor() {
45+
super();
46+
}
47+
}`,
48+
errors: [{messageId: 'preferTemplateLiterals'}],
49+
},
50+
{
51+
filename: 'front_end/ui/components/component/file.ts',
52+
code: `
53+
class SomeWidget extends UI.Widget.Widget {
54+
constructor() {
55+
super();
56+
this.contentElement.className = 'some-class';
57+
this.contentElement.setAttribute('aria-label', 'some-label');
58+
this.contentElement.textContent = 'some-text';
59+
}
60+
}`,
61+
output: `
62+
63+
export const DEFAULT_VIEW = (input, _output, target) => {
64+
render(html\`
65+
<div class="some-class" aria-label="some-label">some-text</div>\`,
66+
target, {host: input});
67+
};
68+
69+
class SomeWidget extends UI.Widget.Widget {
70+
constructor() {
71+
super();
72+
}
73+
}`,
74+
errors: [{messageId: 'preferTemplateLiterals'}],
75+
},
76+
],
77+
});

0 commit comments

Comments
 (0)