chrome-devtools-frontend
Version:
Chrome DevTools UI
204 lines (175 loc) • 8.63 kB
JavaScript
// Copyright 2021 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.
'use strict';
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'check license headers',
category: 'Possible Errors',
},
fixable: 'code',
schema: [],
messages: {
noStaticTagName: 'Found a component class that does not define a static litTagName property.',
noTSInterface: 'Could not find the TS interface declaration for the component {{ tagName }}.',
noDefineCall: 'Could not find a defineComponent() call for the component {{ tagName }}.',
defineCallNonLiteral: 'defineComponent() first argument must be a string literal.',
staticLiteralInvalid: 'static readonly litTagName must use a literal string, with no interpolation.',
duplicateStaticLitTagName: 'found a duplicated litTagName: {{ tagName }}',
litTagNameNotLiteral:
'litTagName must be defined as a string passed in as LitHtml.literal`component-name`, but no tagged template was found.',
staticLiteralNotReadonly: 'static litTagName must be readonly.'
}
},
create: function(context) {
function nodeIsHTMLElementClassDeclaration(node) {
return node.type === 'ClassDeclaration' && node.superClass && node.superClass.name === 'HTMLElement';
}
function findAllComponentClassDefinitions(programNode) {
const nodesWithClassDeclaration = programNode.body.filter(node => {
if (node.type === 'ExportNamedDeclaration' && node.declaration) {
return nodeIsHTMLElementClassDeclaration(node.declaration);
}
return nodeIsHTMLElementClassDeclaration(node);
});
return nodesWithClassDeclaration.map(node => {
return node.type === 'ExportNamedDeclaration' ? node.declaration : node;
});
}
function findComponentTagNameFromStaticProperty(classBodyNode) {
return classBodyNode.body.find(node => {
return node.type === 'PropertyDefinition' && node.key.name === 'litTagName';
});
}
function findCustomElementsDefineComponentCalls(programNode) {
return programNode.body.filter(node => {
if (node.type !== 'ExpressionStatement') {
return false;
}
if (!node.expression.callee) {
return false;
}
if (node.expression.callee.property) {
// matches ComponentHelpers.CustomElements.defineComponent()
return node.expression.callee.property.name === 'defineComponent';
}
// matches defineComponent() which may have been destructured
return node.expression.callee.name === 'defineComponent';
});
}
function findTypeScriptDeclareGlobalHTMLInterfaceCalls(programNode) {
const matchingNode = programNode.body.find(node => {
// matches `declare global {}`
const isGlobalCall = node.type === 'TSModuleDeclaration' && node.id.name === 'global';
if (!isGlobalCall) {
return false;
}
return node.body.body.find(node => {
return node.type === 'TSInterfaceDeclaration' && node.id.name === 'HTMLElementTagNameMap';
});
});
if (!matchingNode) {
return [];
}
return matchingNode.body.body.filter(node => {
return node.type === 'TSInterfaceDeclaration' && node.id.name === 'HTMLElementTagNameMap';
});
}
// Map of litTagName to the class node.
/** @type {Map<string, any}>} */
const componentClassDefinitionLitTagNameNodes = new Map();
/** @type {Set<string>} */
const defineComponentCallsFound = new Set();
/** @type {Set<string>} */
const tsInterfaceExtendedEntriesFound = new Set();
return {
Program(node) {
/* We allow there to be multiple components defined in one file.
* Therefore, this rule gathers all nodes relating to the component
* definition and then checks that for each class found that extends
* HTMLElement, we have:
* 1) the class with a static readonly litTagName
* 2) The call to defineComponent
* 3) The global interface extension
* And that for each of those, the component name string (e.g. 'devtools-foo') is the same.
*/
const componentClassDefinitions = findAllComponentClassDefinitions(node);
if (componentClassDefinitions.length === 0) {
// No components found, our work is done!
return;
}
// Do some checks on the component classes and store the component names that we've found.
for (const componentClassDefinition of componentClassDefinitions) {
const componentTagNameNode = findComponentTagNameFromStaticProperty(componentClassDefinition.body);
if (!componentTagNameNode) {
context.report({node: componentClassDefinition, messageId: 'noStaticTagName'});
return;
}
if (componentTagNameNode.value.type !== 'TaggedTemplateExpression') {
// This means that the value is not LitHtml.literal``. Most likely
// the user forgot to add LitHtml.literal and has defined the value
// as a regular string.
context.report({node: componentTagNameNode, messageId: 'litTagNameNotLiteral'});
return;
}
if (componentTagNameNode.value.quasi.quasis.length > 1) {
// This means that there's >1 template parts, which means they are
// being split by an interpolated value, which is not allowed.
context.report({node: componentTagNameNode, messageId: 'staticLiteralInvalid'});
return;
}
// Enforce that the property is declared as readonly (static readonly litTagName = ...)
if (!componentTagNameNode.readonly) {
context.report({node: componentTagNameNode, messageId: 'staticLiteralNotReadonly'});
return;
}
// Grab the name of the component, e.g:
// LitHtml.literal`devtools-foo` will pull "devtools-foo" out.
const componentTagName = componentTagNameNode.value.quasi.quasis[0].value.cooked;
// Now we ensure that we haven't found this tag name before. If we
// have, we have two components with the same litTagName property,
// which is an error.
if (componentClassDefinitionLitTagNameNodes.has(componentTagName)) {
context.report({node: componentClassDefinition, messageId: 'duplicateStaticLitTagName', data: {tagName: componentTagName}});
}
componentClassDefinitionLitTagNameNodes.set(componentTagName, componentClassDefinition);
}
// Find all defineComponent() calls and store the arguments to them.
// Now find the CustomElements.defineComponent() line
const customElementsDefineCalls = findCustomElementsDefineComponentCalls(node);
for (const customElementsDefineCall of customElementsDefineCalls) {
const firstArgumentToDefineCall = customElementsDefineCall.expression.arguments[0];
if (!firstArgumentToDefineCall || firstArgumentToDefineCall.type !== 'Literal') {
context.report({
node: customElementsDefineCall,
messageId: 'defineCallNonLiteral',
});
return;
}
const tagNamePassedToDefineCall = firstArgumentToDefineCall.value;
defineComponentCallsFound.add(tagNamePassedToDefineCall);
}
const allTSDeclareGlobalInterfaceCalls = findTypeScriptDeclareGlobalHTMLInterfaceCalls(node);
for (const interfaceDeclaration of allTSDeclareGlobalInterfaceCalls) {
for (const node of interfaceDeclaration.body.body) {
if (node.type === 'TSPropertySignature') {
tsInterfaceExtendedEntriesFound.add(node.key.value);
}
}
}
for (const [tagName, classNode] of componentClassDefinitionLitTagNameNodes) {
// Check that each tagName has a matching entry in both other places we expect it.
if (!defineComponentCallsFound.has(tagName)) {
context.report({node: classNode, messageId: 'noDefineCall', data: {tagName}});
}
if (!tsInterfaceExtendedEntriesFound.has(tagName)) {
context.report({node: classNode, messageId: 'noTSInterface', data: {tagName}});
}
}
// if we got here, everything is valid and the name is correct in all three locations
}
};
}
};