chrome-devtools-frontend
Version:
Chrome DevTools UI
308 lines (286 loc) • 10.6 kB
text/typescript
// 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;
}