| // 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.', |
| 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'; |
| }); |
| } |
| |
| /** @type {Set<{classNode: any, tagName: string}>} */ |
| const componentClassDefinitionLitTagNamesFound = new Set(); |
| |
| /** @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; |
| componentClassDefinitionLitTagNamesFound.add( |
| {tagName: componentTagName, classNode: 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 foundComponentClass of componentClassDefinitionLitTagNamesFound) { |
| const {tagName, classNode} = foundComponentClass; |
| // 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 |
| } |
| }; |
| } |
| }; |