UNPKG

chrome-devtools-frontend

Version:
308 lines (286 loc) • 10.6 kB
// Copyright 2025 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview A library to associate DOM fragments with their construction code. */ import type {TSESLint, TSESTree} from '@typescript-eslint/utils'; import {getEnclosingProperty} from './ast.ts'; import {ClassMember} from './class-member.ts'; type Variable = TSESLint.Scope.Variable; type Node = TSESTree.Node; type Identifier = TSESTree.Identifier; type SourceCode = TSESLint.SourceCode; type Scope = TSESLint.Scope.Scope; const domFragments = new Map<Node|ClassMember|Variable, DomFragment>(); export class DomFragment { tagName?: string; classList: Array<Node|string> = []; attributes: Array<{key: string, value: Node|string}> = []; booleanAttributes: Array<{key: string, value: Node|string}> = []; style: Array<{key: string, value: Node}> = []; eventListeners: Array<{key: string, value: Node}> = []; bindings: Array<{key: string, value: Node|string}> = []; directives: Array<{name: string, arguments: Node[]}> = []; textContent?: Node|string; children: DomFragment[] = []; parent?: DomFragment; expression?: string; widgetClass?: Node; replacer?: (fixer: TSESLint.RuleFixer, template: string) => TSESLint.RuleFix; initializer?: Node; references: Array<{node: Node, processed?: boolean}> = []; static get(node: Node, sourceCode: SourceCode): DomFragment|undefined { return domFragments.get(getKey(node, sourceCode)); } static getOrCreate(node: Node, sourceCode: SourceCode): DomFragment { const key = getKey(node, sourceCode); let result = domFragments.get(key); if (!result) { result = new DomFragment(); domFragments.set(key, result); if ('parent' in key) { result.expression = sourceCode.getText(node); result.references.push({node}); } else if (key instanceof ClassMember) { result.expression = sourceCode.getText(node); const references: Array<{node: Node, processed?: boolean}> = []; Object.defineProperty(result, 'references', { get: () => { if (key.references.size === references.length) { return references; } for (const reference of key.references) { if (!references.some(r => r.node === reference)) { references.push({node: reference}); } } return references; }, set: () => {}, }); Object.defineProperty(result, 'initializer', { get: () => key.initializer, set: () => {}, }); } else if ('references' in key) { result.references = key.references.filter(r => !key.identifiers.includes(r.identifier as Identifier)) .map(r => ({node: r.identifier})); const initializer = key.identifiers[0]; if (initializer?.parent?.type === 'VariableDeclarator') { result.initializer = initializer.parent?.init ?? undefined; if (result.initializer?.type === 'TSAsExpression') { result.initializer = result.initializer.expression; } } result.expression = key.name; } } return result; } static set(node: Node, sourceCode: SourceCode, domFragment: DomFragment) { const key = getKey(node, sourceCode); domFragments.set(key, domFragment); } static clear() { domFragments.clear(); } static values() { return domFragments.values(); } toTemplateLiteral(sourceCode: Readonly<SourceCode>, indent = 4): string[] { const components: string[] = []; const MAX_LINE_LENGTH = 100; components.push(`\n${' '.repeat(indent)}`); let lineLength = indent; if (this.expression && !this.tagName) { if (this.expression.startsWith('`') && this.expression.endsWith('`')) { components.push(this.expression.slice(1, -1).trim()); } else { const expression = (this.references.every(r => r.processed) && this.initializer) ? sourceCode.getText(this.initializer) : this.expression; components.push('${', expression, '}'); } return components; } function toOutputString(node: Node|string, quoteLiterals = false): string { if (typeof node === 'string') { return node; } if (node.type === 'Literal' && !quoteLiterals) { return node.value?.toString() ?? ''; } if (node.type === 'UnaryExpression' && node.operator === '-' && node.argument.type === 'Literal') { return '-' + node.argument.value; } const text = sourceCode.getText(node); if (node.type === 'TemplateLiteral') { return text.substr(1, text.length - 2); } return '${' + text + '}'; } function appendExpression(expression) { if (lineLength + expression.length + 1 > MAX_LINE_LENGTH) { components.push(`\n${' '.repeat(indent + 4)}`); lineLength = expression.length + indent + 4; } else { if (expression.match(/^[a-zA-Z0-9?.$@]/)) { components.push(' '); ++lineLength; } lineLength += expression.length; } components.push(expression); } if (this.tagName) { components.push('<', this.tagName); lineLength += this.tagName.length + 1; } if (this.classList.length) { appendExpression( `class="${this.classList.map(c => toOutputString(c)).join(' ')}"`, ); } for (const attribute of this.attributes || []) { const value = attribute.value; const valueEmpty = typeof value === 'string' ? value === '' : value.type === 'Literal' && value.value === ''; if (valueEmpty) { appendExpression(attribute.key); } else { appendExpression(`${attribute.key}=${attributeValue(toOutputString(value))}`); } } for (const attribute of this.booleanAttributes || []) { const value = attribute.value; const isFalse = typeof value === 'string' ? value === 'false' : value.type === 'Literal' && value.value === false; if (isFalse) { continue; } const isTrue = typeof value === 'string' ? value === 'true' : value.type === 'Literal' && value.value === true; if (isTrue) { appendExpression(attribute.key); } else { appendExpression(`?${attribute.key}=${attributeValue(toOutputString(value))}`); } } for (const eventListener of this.eventListeners || []) { appendExpression( `@${eventListener.key}=${ attributeValue( toOutputString(eventListener.value), )}`, ); } if (this.widgetClass) { appendExpression(`.widgetConfig=\${widgetConfig(${sourceCode.getText(this.widgetClass)}`); if (this.bindings.length) { appendExpression(','); if (this.bindings.length === 1) { appendExpression(`{${this.bindings[0].key}: ${ typeof this.bindings[0].value === 'string' ? this.bindings[0].value : sourceCode.getText(this.bindings[0].value)}}`); } else { appendExpression('{'); for (const binding of this.bindings) { appendExpression(`${binding.key}: ${ typeof binding.value === 'string' ? binding.value : sourceCode.getText(binding.value)},`); } appendExpression('}'); } } appendExpression(')}'); } else { for (const binding of this.bindings || []) { appendExpression( `.${binding.key}=${ toOutputString( binding.value, /* quoteLiterals=*/ true, )}`, ); } } for (const directive of this.directives || []) { appendExpression(`\${${directive.name}(${directive.arguments.map(a => sourceCode.getText(a)).join(', ')})}`); } if (this.style.length) { const style = this.style.map(s => `${s.key}:${toOutputString(s.value)}`).join('; '); appendExpression(`style="${style}"`); } if (lineLength > MAX_LINE_LENGTH) { components.push(`\n${' '.repeat(indent)}`); } components.push('>'); if (this.textContent) { components.push(toOutputString(this.textContent)); } if (this.children?.length) { for (const child of this.children || []) { components.push(...child.toTemplateLiteral(sourceCode, indent + 2)); } components.push(`\n${' '.repeat(indent)}`); } if (this.tagName && this.tagName !== 'input') { components.push('</', this.tagName, '>'); } return components; } appendChild(node: Node, sourceCode: SourceCode, processed = true): DomFragment { return this.insertChildAt(node, this.children.length, sourceCode, processed); } insertChildAt(node: Node, index: number, sourceCode: SourceCode, processed = true): DomFragment { const child = DomFragment.getOrCreate(node, sourceCode); this.children.splice(index, 0, child); child.parent = this; if (processed) { for (const reference of child.references) { if (reference.node === node) { reference.processed = true; } } } return child; } } function getEnclosingVariable(node: Node, sourceCode: SourceCode): Variable|null { if (node.type === 'Identifier') { let scope: Scope|null = sourceCode.getScope(node); const variableName = node.name; while (scope) { const variable = scope.variables.find(v => v.name === variableName); if (variable) { return variable; } scope = scope.upper; } } if (node.parent?.type === 'VariableDeclarator') { const variables = sourceCode.getDeclaredVariables(node.parent); if (variables.length > 1) { return null; // Destructuring assignment } return variables[0]; } return null; } function attributeValue(outputString: string): string { if (outputString.startsWith('${') && outputString.endsWith('}')) { return outputString; } return '"' + outputString + '"'; } function getKey(node: Node, sourceCode: SourceCode): Node|ClassMember|Variable { const variable = getEnclosingVariable(node, sourceCode); if (variable) { return variable; } const property = getEnclosingProperty(node); if (property) { const classMember = ClassMember.getOrCreate(property, sourceCode); if (classMember) { return classMember; } } return node; }