Skip to content

Commit dfdc3f7

Browse files
43081jkevinpschaaf
andauthored
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>
1 parent 83ff025 commit dfdc3f7

21 files changed

Lines changed: 1131 additions & 100 deletions

File tree

.changeset/big-squids-drive.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@lit-labs/analyzer': minor
3+
---
4+
5+
Added analysys of vanilla custom elements that extend HTMLElement.

packages/labs/analyzer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type {
2121
Parameter,
2222
Return,
2323
LitElementDeclaration,
24+
CustomElementDeclaration,
2425
LitElementExport,
2526
PackageJson,
2627
ModuleWithLitElementDeclarations,
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* @license
3+
* Copyright 2022 Google LLC
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
*/
6+
7+
/**
8+
* @fileoverview
9+
*
10+
* Utilities for analyzing native custom elements (i.e. `HTMLElement`)
11+
* subclasses.
12+
*/
13+
14+
import ts from 'typescript';
15+
import {getClassMembers, getHeritage} from '../javascript/classes.js';
16+
import {
17+
AnalyzerInterface,
18+
CustomElementDeclaration,
19+
Event,
20+
NamedDescribed,
21+
} from '../model.js';
22+
import {addEventsToMap} from './events.js';
23+
import {parseNodeJSDocInfo, parseNamedJSDocInfo} from '../javascript/jsdoc.js';
24+
25+
const _isCustomElementClassDeclaration = (
26+
t: ts.BaseType,
27+
analyzer: AnalyzerInterface
28+
): boolean => {
29+
const declarations = t.getSymbol()?.getDeclarations();
30+
return (
31+
declarations?.some(
32+
(declaration) =>
33+
(ts.isInterfaceDeclaration(declaration) &&
34+
declaration.name?.text === 'HTMLElement') ||
35+
isCustomElementSubclass(declaration, analyzer)
36+
) === true
37+
);
38+
};
39+
40+
export type CustomElementClassDeclaration = ts.ClassDeclaration & {
41+
_customElementBrand: never;
42+
};
43+
44+
export const isCustomElementSubclass = (
45+
node: ts.Node,
46+
analyzer: AnalyzerInterface
47+
): node is CustomElementClassDeclaration => {
48+
if (!ts.isClassLike(node)) {
49+
return false;
50+
}
51+
if (getTagName(node) !== undefined) {
52+
return true;
53+
}
54+
const checker = analyzer.program.getTypeChecker();
55+
const type = checker.getTypeAtLocation(node) as ts.InterfaceType;
56+
const baseTypes = checker.getBaseTypes(type);
57+
for (const t of baseTypes) {
58+
if (_isCustomElementClassDeclaration(t, analyzer)) {
59+
return true;
60+
}
61+
}
62+
return false;
63+
};
64+
65+
export const getTagName = (
66+
node: ts.ClassDeclaration | ts.ClassExpression
67+
): string | undefined => {
68+
const jsdocTag = ts
69+
.getJSDocTags(node)
70+
.find((tag) => tag.tagName.text.toLowerCase() === 'customelement');
71+
72+
if (jsdocTag && typeof jsdocTag.comment === 'string') {
73+
return jsdocTag.comment.trim();
74+
}
75+
76+
let tagName: string | undefined = undefined;
77+
78+
// Otherwise, look for imperative define in the form of:
79+
// `customElements.define('x-foo', XFoo);`
80+
node.parent.forEachChild((child) => {
81+
if (
82+
ts.isExpressionStatement(child) &&
83+
ts.isCallExpression(child.expression) &&
84+
ts.isPropertyAccessExpression(child.expression.expression) &&
85+
child.expression.arguments.length >= 2
86+
) {
87+
const [tagNameArg, ctorArg] = child.expression.arguments;
88+
const {expression, name} = child.expression.expression;
89+
if (
90+
ts.isIdentifier(expression) &&
91+
expression.text === 'customElements' &&
92+
ts.isIdentifier(name) &&
93+
name.text === 'define' &&
94+
ts.isStringLiteralLike(tagNameArg) &&
95+
ts.isIdentifier(ctorArg) &&
96+
ctorArg.text === node.name?.text
97+
) {
98+
tagName = tagNameArg.text;
99+
}
100+
}
101+
});
102+
103+
return tagName;
104+
};
105+
106+
/**
107+
* Adds name, description, and summary info for a given jsdoc tag into the
108+
* provided map.
109+
*/
110+
const addNamedJSDocInfoToMap = (
111+
map: Map<string, NamedDescribed>,
112+
tag: ts.JSDocTag
113+
) => {
114+
const info = parseNamedJSDocInfo(tag);
115+
if (info !== undefined) {
116+
map.set(info.name, info);
117+
}
118+
};
119+
120+
/**
121+
* Parses element metadata from jsDoc tags from a LitElement declaration into
122+
* Maps of <name, info>.
123+
*/
124+
export const getJSDocData = (
125+
node: ts.ClassDeclaration,
126+
analyzer: AnalyzerInterface
127+
) => {
128+
const events = new Map<string, Event>();
129+
const slots = new Map<string, NamedDescribed>();
130+
const cssProperties = new Map<string, NamedDescribed>();
131+
const cssParts = new Map<string, NamedDescribed>();
132+
const jsDocTags = ts.getJSDocTags(node);
133+
if (jsDocTags !== undefined) {
134+
for (const tag of jsDocTags) {
135+
switch (tag.tagName.text) {
136+
case 'fires':
137+
addEventsToMap(tag, events, analyzer);
138+
break;
139+
case 'slot':
140+
addNamedJSDocInfoToMap(slots, tag);
141+
break;
142+
case 'cssProp':
143+
addNamedJSDocInfoToMap(cssProperties, tag);
144+
break;
145+
case 'cssProperty':
146+
addNamedJSDocInfoToMap(cssProperties, tag);
147+
break;
148+
case 'cssPart':
149+
addNamedJSDocInfoToMap(cssParts, tag);
150+
break;
151+
}
152+
}
153+
}
154+
return {
155+
...parseNodeJSDocInfo(node),
156+
events,
157+
slots,
158+
cssProperties,
159+
cssParts,
160+
};
161+
};
162+
163+
export const getCustomElementDeclaration = (
164+
node: CustomElementClassDeclaration,
165+
analyzer: AnalyzerInterface
166+
): CustomElementDeclaration => {
167+
return new CustomElementDeclaration({
168+
tagname: getTagName(node),
169+
name: node.name?.text ?? '',
170+
node,
171+
...getJSDocData(node, analyzer),
172+
getHeritage: () => getHeritage(node, analyzer),
173+
...getClassMembers(node, analyzer),
174+
});
175+
};

packages/labs/analyzer/src/lib/lit-element/events.ts renamed to packages/labs/analyzer/src/lib/custom-elements/events.ts

File renamed without changes.

packages/labs/analyzer/src/lib/javascript/classes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import {
3535
} from '../utils.js';
3636
import {getFunctionLikeInfo} from './functions.js';
3737
import {getTypeForNode} from '../types.js';
38+
import {
39+
isCustomElementSubclass,
40+
getCustomElementDeclaration,
41+
} from '../custom-elements/custom-elements.js';
3842

3943
/**
4044
* Returns an analyzer `ClassDeclaration` model for the given
@@ -47,6 +51,9 @@ const getClassDeclaration = (
4751
if (isLitElementSubclass(declaration, analyzer)) {
4852
return getLitElementDeclaration(declaration, analyzer);
4953
}
54+
if (isCustomElementSubclass(declaration, analyzer)) {
55+
return getCustomElementDeclaration(declaration, analyzer);
56+
}
5057
return new ClassDeclaration({
5158
// TODO(kschaaf): support anonymous class expressions when assigned to a const
5259
name: declaration.name?.text ?? '',

packages/labs/analyzer/src/lib/lit-element/lit-element.ts

Lines changed: 7 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,13 @@
1212

1313
import ts from 'typescript';
1414
import {getClassMembers, getHeritage} from '../javascript/classes.js';
15-
import {parseNodeJSDocInfo, parseNamedJSDocInfo} from '../javascript/jsdoc.js';
16-
import {
17-
LitElementDeclaration,
18-
AnalyzerInterface,
19-
Event,
20-
NamedDescribed,
21-
} from '../model.js';
15+
import {LitElementDeclaration, AnalyzerInterface} from '../model.js';
2216
import {isCustomElementDecorator} from './decorators.js';
23-
import {addEventsToMap} from './events.js';
2417
import {getProperties} from './properties.js';
18+
import {
19+
getJSDocData,
20+
getTagName as getCustomElementTagName,
21+
} from '../custom-elements/custom-elements.js';
2522

2623
/**
2724
* Gets an analyzer LitElementDeclaration object from a ts.ClassDeclaration
@@ -43,63 +40,6 @@ export const getLitElementDeclaration = (
4340
});
4441
};
4542

46-
/**
47-
* Parses element metadata from jsDoc tags from a LitElement declaration into
48-
* Maps of <name, info>.
49-
*/
50-
export const getJSDocData = (
51-
node: LitClassDeclaration,
52-
analyzer: AnalyzerInterface
53-
) => {
54-
const events = new Map<string, Event>();
55-
const slots = new Map<string, NamedDescribed>();
56-
const cssProperties = new Map<string, NamedDescribed>();
57-
const cssParts = new Map<string, NamedDescribed>();
58-
const jsDocTags = ts.getJSDocTags(node);
59-
if (jsDocTags !== undefined) {
60-
for (const tag of jsDocTags) {
61-
switch (tag.tagName.text) {
62-
case 'fires':
63-
addEventsToMap(tag, events, analyzer);
64-
break;
65-
case 'slot':
66-
addNamedJSDocInfoToMap(slots, tag);
67-
break;
68-
case 'cssProp':
69-
addNamedJSDocInfoToMap(cssProperties, tag);
70-
break;
71-
case 'cssProperty':
72-
addNamedJSDocInfoToMap(cssProperties, tag);
73-
break;
74-
case 'cssPart':
75-
addNamedJSDocInfoToMap(cssParts, tag);
76-
break;
77-
}
78-
}
79-
}
80-
return {
81-
...parseNodeJSDocInfo(node),
82-
events,
83-
slots,
84-
cssProperties,
85-
cssParts,
86-
};
87-
};
88-
89-
/**
90-
* Adds name, description, and summary info for a given jsdoc tag into the
91-
* provided map.
92-
*/
93-
const addNamedJSDocInfoToMap = (
94-
map: Map<string, NamedDescribed>,
95-
tag: ts.JSDocTag
96-
) => {
97-
const info = parseNamedJSDocInfo(tag);
98-
if (info !== undefined) {
99-
map.set(info.name, info);
100-
}
101-
};
102-
10343
/**
10444
* Returns true if this type represents the actual LitElement class.
10545
*/
@@ -179,7 +119,6 @@ export const isLitElementSubclass = (
179119
* @returns
180120
*/
181121
export const getTagName = (declaration: LitClassDeclaration) => {
182-
let tagName: string | undefined = undefined;
183122
const customElementDecorator = declaration.decorators?.find(
184123
isCustomElementDecorator
185124
);
@@ -189,32 +128,7 @@ export const getTagName = (declaration: LitClassDeclaration) => {
189128
ts.isStringLiteral(customElementDecorator.expression.arguments[0])
190129
) {
191130
// Get tag from decorator: `@customElement('x-foo')`
192-
tagName = customElementDecorator.expression.arguments[0].text;
193-
} else {
194-
// Otherwise, look for imperative define in the form of:
195-
// `customElements.define('x-foo', XFoo);`
196-
declaration.parent.forEachChild((child) => {
197-
if (
198-
ts.isExpressionStatement(child) &&
199-
ts.isCallExpression(child.expression) &&
200-
ts.isPropertyAccessExpression(child.expression.expression) &&
201-
child.expression.arguments.length >= 2
202-
) {
203-
const [tagNameArg, ctorArg] = child.expression.arguments;
204-
const {expression, name} = child.expression.expression;
205-
if (
206-
ts.isIdentifier(expression) &&
207-
expression.text === 'customElements' &&
208-
ts.isIdentifier(name) &&
209-
name.text === 'define' &&
210-
ts.isStringLiteralLike(tagNameArg) &&
211-
ts.isIdentifier(ctorArg) &&
212-
ctorArg.text === declaration.name?.text
213-
) {
214-
tagName = tagNameArg.text;
215-
}
216-
}
217-
});
131+
return customElementDecorator.expression.arguments[0].text;
218132
}
219-
return tagName;
133+
return getCustomElementTagName(declaration);
220134
};

0 commit comments

Comments
 (0)