|
| 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 | +}; |
0 commit comments