Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat (labs/analyzer): support custom element analysis (#3621)
* feat (labs/analyzer): support custom element analysis This separates the current lit-element analysis into two layers: * custom elements * lit Primarily this is so we can detect non-lit elements we may depend on inside a lit project, giving the user full coverage of all the elements they're consuming. * Align vanilla jsdoc tests to LitElement tests * Add changeset --------- Co-authored-by: Kevin Schaaf <kschaaf@google.com>
- Loading branch information
1 parent
83ff025
commit dfdc3f7
Showing
21 changed files
with
1,131 additions
and
100 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@lit-labs/analyzer': minor | ||
--- | ||
|
||
Added analysys of vanilla custom elements that extend HTMLElement. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
175 changes: 175 additions & 0 deletions
175
packages/labs/analyzer/src/lib/custom-elements/custom-elements.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
/** | ||
* @license | ||
* Copyright 2022 Google LLC | ||
* SPDX-License-Identifier: BSD-3-Clause | ||
*/ | ||
|
||
/** | ||
* @fileoverview | ||
* | ||
* Utilities for analyzing native custom elements (i.e. `HTMLElement`) | ||
* subclasses. | ||
*/ | ||
|
||
import ts from 'typescript'; | ||
import {getClassMembers, getHeritage} from '../javascript/classes.js'; | ||
import { | ||
AnalyzerInterface, | ||
CustomElementDeclaration, | ||
Event, | ||
NamedDescribed, | ||
} from '../model.js'; | ||
import {addEventsToMap} from './events.js'; | ||
import {parseNodeJSDocInfo, parseNamedJSDocInfo} from '../javascript/jsdoc.js'; | ||
|
||
const _isCustomElementClassDeclaration = ( | ||
t: ts.BaseType, | ||
analyzer: AnalyzerInterface | ||
): boolean => { | ||
const declarations = t.getSymbol()?.getDeclarations(); | ||
return ( | ||
declarations?.some( | ||
(declaration) => | ||
(ts.isInterfaceDeclaration(declaration) && | ||
declaration.name?.text === 'HTMLElement') || | ||
isCustomElementSubclass(declaration, analyzer) | ||
) === true | ||
); | ||
}; | ||
|
||
export type CustomElementClassDeclaration = ts.ClassDeclaration & { | ||
_customElementBrand: never; | ||
}; | ||
|
||
export const isCustomElementSubclass = ( | ||
node: ts.Node, | ||
analyzer: AnalyzerInterface | ||
): node is CustomElementClassDeclaration => { | ||
if (!ts.isClassLike(node)) { | ||
return false; | ||
} | ||
if (getTagName(node) !== undefined) { | ||
return true; | ||
} | ||
const checker = analyzer.program.getTypeChecker(); | ||
const type = checker.getTypeAtLocation(node) as ts.InterfaceType; | ||
const baseTypes = checker.getBaseTypes(type); | ||
for (const t of baseTypes) { | ||
if (_isCustomElementClassDeclaration(t, analyzer)) { | ||
return true; | ||
} | ||
} | ||
return false; | ||
}; | ||
|
||
export const getTagName = ( | ||
node: ts.ClassDeclaration | ts.ClassExpression | ||
): string | undefined => { | ||
const jsdocTag = ts | ||
.getJSDocTags(node) | ||
.find((tag) => tag.tagName.text.toLowerCase() === 'customelement'); | ||
|
||
if (jsdocTag && typeof jsdocTag.comment === 'string') { | ||
return jsdocTag.comment.trim(); | ||
} | ||
|
||
let tagName: string | undefined = undefined; | ||
|
||
// Otherwise, look for imperative define in the form of: | ||
// `customElements.define('x-foo', XFoo);` | ||
node.parent.forEachChild((child) => { | ||
if ( | ||
ts.isExpressionStatement(child) && | ||
ts.isCallExpression(child.expression) && | ||
ts.isPropertyAccessExpression(child.expression.expression) && | ||
child.expression.arguments.length >= 2 | ||
) { | ||
const [tagNameArg, ctorArg] = child.expression.arguments; | ||
const {expression, name} = child.expression.expression; | ||
if ( | ||
ts.isIdentifier(expression) && | ||
expression.text === 'customElements' && | ||
ts.isIdentifier(name) && | ||
name.text === 'define' && | ||
ts.isStringLiteralLike(tagNameArg) && | ||
ts.isIdentifier(ctorArg) && | ||
ctorArg.text === node.name?.text | ||
) { | ||
tagName = tagNameArg.text; | ||
} | ||
} | ||
}); | ||
|
||
return tagName; | ||
}; | ||
|
||
/** | ||
* Adds name, description, and summary info for a given jsdoc tag into the | ||
* provided map. | ||
*/ | ||
const addNamedJSDocInfoToMap = ( | ||
map: Map<string, NamedDescribed>, | ||
tag: ts.JSDocTag | ||
) => { | ||
const info = parseNamedJSDocInfo(tag); | ||
if (info !== undefined) { | ||
map.set(info.name, info); | ||
} | ||
}; | ||
|
||
/** | ||
* Parses element metadata from jsDoc tags from a LitElement declaration into | ||
* Maps of <name, info>. | ||
*/ | ||
export const getJSDocData = ( | ||
node: ts.ClassDeclaration, | ||
analyzer: AnalyzerInterface | ||
) => { | ||
const events = new Map<string, Event>(); | ||
const slots = new Map<string, NamedDescribed>(); | ||
const cssProperties = new Map<string, NamedDescribed>(); | ||
const cssParts = new Map<string, NamedDescribed>(); | ||
const jsDocTags = ts.getJSDocTags(node); | ||
if (jsDocTags !== undefined) { | ||
for (const tag of jsDocTags) { | ||
switch (tag.tagName.text) { | ||
case 'fires': | ||
addEventsToMap(tag, events, analyzer); | ||
break; | ||
case 'slot': | ||
addNamedJSDocInfoToMap(slots, tag); | ||
break; | ||
case 'cssProp': | ||
addNamedJSDocInfoToMap(cssProperties, tag); | ||
break; | ||
case 'cssProperty': | ||
addNamedJSDocInfoToMap(cssProperties, tag); | ||
break; | ||
case 'cssPart': | ||
addNamedJSDocInfoToMap(cssParts, tag); | ||
break; | ||
} | ||
} | ||
} | ||
return { | ||
...parseNodeJSDocInfo(node), | ||
events, | ||
slots, | ||
cssProperties, | ||
cssParts, | ||
}; | ||
}; | ||
|
||
export const getCustomElementDeclaration = ( | ||
node: CustomElementClassDeclaration, | ||
analyzer: AnalyzerInterface | ||
): CustomElementDeclaration => { | ||
return new CustomElementDeclaration({ | ||
tagname: getTagName(node), | ||
name: node.name?.text ?? '', | ||
node, | ||
...getJSDocData(node, analyzer), | ||
getHeritage: () => getHeritage(node, analyzer), | ||
...getClassMembers(node, analyzer), | ||
}); | ||
}; |
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.