diff --git a/.changeset/nice-steaks-hope.md b/.changeset/nice-steaks-hope.md new file mode 100644 index 0000000000..2b94e45e49 --- /dev/null +++ b/.changeset/nice-steaks-hope.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/analyzer': minor +--- + +Add lib/lit-html/template.js module with initial template utilities. diff --git a/packages/labs/analyzer/package.json b/packages/labs/analyzer/package.json index a2cd04fb04..f6cdc4f89f 100644 --- a/packages/labs/analyzer/package.json +++ b/packages/labs/analyzer/package.json @@ -93,7 +93,8 @@ ], "exports": { ".": "./index.js", - "./package-analyzer.js": "./package-analyzer.js" + "./package-analyzer.js": "./package-analyzer.js", + "./lib/*.js": "./lib/*.js" }, "dependencies": { "package-json-type": "^1.0.3", diff --git a/packages/labs/analyzer/src/lib/lit-html/template.ts b/packages/labs/analyzer/src/lib/lit-html/template.ts new file mode 100644 index 0000000000..fec7dc6d25 --- /dev/null +++ b/packages/labs/analyzer/src/lib/lit-html/template.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * @fileoverview + * + * Utilities for analyzing lit-html templates. + */ + +import type ts from 'typescript'; + +type TypeScript = typeof ts; + +/** + * Returns true if the specifier is know to export the Lit html template tag. + * + * This can be used in a hueristic to determine if a template is a lit-html + * template. + */ +export const isKnownLitModuleSpecifier = (specifier: string): boolean => { + return ( + specifier === 'lit' || + specifier === 'lit-html' || + specifier === 'lit-element' + ); +}; + +/** + * Returns true if the given node is a tagged template expression with the + * lit-html template tag. + */ +export const isLitTaggedTemplateExpression = ( + node: ts.Node, + ts: TypeScript, + checker: ts.TypeChecker +): node is ts.TaggedTemplateExpression => { + if (!ts.isTaggedTemplateExpression(node)) { + return false; + } + if (ts.isIdentifier(node.tag)) { + return isResolvedIdentifierLitHtmlTemplate(node.tag, ts, checker); + } + if (ts.isPropertyAccessExpression(node.tag)) { + return isResolvedPropertyAccessExpressionLitHtmlNamespace( + node.tag, + ts, + checker + ); + } + return false; +}; + +/** + * Resolve a common pattern of using the `html` identifier of a lit namespace + * import. + * + * E.g.: + * + * ```ts + * import * as identifier from 'lit'; + * identifier.html`

I am compiled!

`; + * ``` + */ +const isResolvedPropertyAccessExpressionLitHtmlNamespace = ( + node: ts.PropertyAccessExpression, + ts: TypeScript, + checker: ts.TypeChecker +): boolean => { + // Ensure propertyAccessExpression ends with `.html`. + if (ts.isIdentifier(node.name) && node.name.text !== 'html') { + return false; + } + // Expect a namespace preceding `html`, `.html`. + if (!ts.isIdentifier(node.expression)) { + return false; + } + + // Resolve the namespace if it has been aliased. + const symbol = checker.getSymbolAtLocation(node.expression); + if (!symbol) { + return false; + } + const namespaceImport = symbol.declarations?.[0]; + if (!namespaceImport || !ts.isNamespaceImport(namespaceImport)) { + return false; + } + const importDeclaration = namespaceImport.parent.parent; + const specifier = importDeclaration.moduleSpecifier; + if (!ts.isStringLiteral(specifier)) { + return false; + } + return isKnownLitModuleSpecifier(specifier.text); +}; + +/** + * Resolve the tag function identifier back to an import, returning true if + * the original reference was the `html` export from `lit` or `lit-html`. + * + * This check handles: aliasing and reassigning the import. + * + * ```ts + * import {html as h} from 'lit'; + * h``; + * // isResolvedIdentifierLitHtmlTemplate() returns true + * ``` + * + * ```ts + * import {html} from 'lit-html/static.js'; + * html`false`; + * // isResolvedIdentifierLitHtmlTemplate() returns false + * ``` + * + * @param node a TaggedTemplateExpression tag + */ +const isResolvedIdentifierLitHtmlTemplate = ( + node: ts.Identifier, + ts: TypeScript, + checker: ts.TypeChecker +): boolean => { + const symbol = checker.getSymbolAtLocation(node); + if (!symbol) { + return false; + } + const templateImport = symbol.declarations?.[0]; + if (!templateImport || !ts.isImportSpecifier(templateImport)) { + return false; + } + + // An import specifier has the following structures: + // + // `import { as } from ;` + // `import {} from ;` + // + // This check allows aliasing `html` by ensuring propertyName is `html`. + // Thus `{html as myHtml}` is a valid template that can be compiled. + // Otherwise a compilable template must be a direct import of lit's `html` + // tag function. + if ( + (templateImport.propertyName && + templateImport.propertyName.text !== 'html') || + (!templateImport.propertyName && templateImport.name.text !== 'html') + ) { + return false; + } + const namedImport = templateImport.parent; + if (!ts.isNamedImports(namedImport)) { + return false; + } + const importClause = namedImport.parent; + if (!ts.isImportClause(importClause)) { + return false; + } + const importDeclaration = importClause.parent; + if (!ts.isImportDeclaration(importDeclaration)) { + return false; + } + const specifier = importDeclaration.moduleSpecifier; + if (!ts.isStringLiteral(specifier)) { + return false; + } + return isKnownLitModuleSpecifier(specifier.text); +}; diff --git a/packages/labs/analyzer/src/test/server/lit-html/template_test.ts b/packages/labs/analyzer/src/test/server/lit-html/template_test.ts new file mode 100644 index 0000000000..2230e9b90c --- /dev/null +++ b/packages/labs/analyzer/src/test/server/lit-html/template_test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {suite} from 'uvu'; +// eslint-disable-next-line import/extensions +import * as assert from 'uvu/assert'; +import type ts from 'typescript'; + +import { + AnalyzerTestContext, + languages, + setupAnalyzerForTest, +} from '../utils.js'; +import {ClassDeclaration} from '../../../lib/model.js'; +import {isLitTaggedTemplateExpression} from '../../../lib/lit-html/template.js'; + +for (const lang of languages) { + const test = suite(`lit-html tests (${lang})`); + + test.before((ctx) => { + setupAnalyzerForTest(ctx, lang, 'basic-elements'); + }); + + test('isLitHtmlTemplateTag', ({getModule, analyzer, typescript}) => { + const elementAModule = getModule('element-a')!; + const decl = elementAModule.declarations[0]; + + // get to the lit-html template tag + const renderMethod = (decl as ClassDeclaration).getMethod('render')!; + const statement = renderMethod.node.body!.statements[0]; + + assert.is(typescript.isReturnStatement(statement), true); + const returnStatement = statement as ts.ReturnStatement; + assert.ok(returnStatement.expression); + assert.is( + typescript.isTaggedTemplateExpression(returnStatement.expression), + true + ); + const expression = + returnStatement.expression as ts.TaggedTemplateExpression; + assert.is(typescript.isIdentifier(expression.tag), true); + assert.is( + isLitTaggedTemplateExpression( + expression, + analyzer.typescript, + analyzer.program.getTypeChecker() + ), + true + ); + }); + + test.run(); +} diff --git a/packages/labs/analyzer/src/test/server/utils.ts b/packages/labs/analyzer/src/test/server/utils.ts index badb4b4140..ec6483df0b 100644 --- a/packages/labs/analyzer/src/test/server/utils.ts +++ b/packages/labs/analyzer/src/test/server/utils.ts @@ -206,6 +206,7 @@ export class InMemoryAnalyzer extends Analyzer { export interface AnalyzerTestContext { analyzer: Analyzer; + typescript: typeof ts; packagePath: AbsolutePath; getModule: (name: string) => Module; } @@ -233,6 +234,7 @@ export const setupAnalyzerForTest = ( ); ctx.packagePath = packagePath; ctx.analyzer = analyzer; + ctx.typescript = analyzer.typescript; ctx.getModule = getModule; } catch (error) { // Uvu has a bug where it silently ignores failures in before and after, diff --git a/packages/labs/compiler/package.json b/packages/labs/compiler/package.json index 9d7b3dcc15..6d550b2146 100644 --- a/packages/labs/compiler/package.json +++ b/packages/labs/compiler/package.json @@ -44,7 +44,8 @@ "command": "tsc --build --pretty", "dependencies": [ "../../lit-html:build:ts:types", - "../../lit-html:build:rollup" + "../../lit-html:build:rollup", + "../analyzer:build" ], "files": [ "src/**/*.ts", @@ -102,6 +103,7 @@ } }, "dependencies": { + "@lit-labs/analyzer": "^0.11.0", "@parse5/tools": "^0.3.0", "lit-html": "^3.1.2", "parse5": "^7.1.2", diff --git a/packages/labs/compiler/src/lib/type-checker.ts b/packages/labs/compiler/src/lib/type-checker.ts index 2eb6673b19..d9f28b0d75 100644 --- a/packages/labs/compiler/src/lib/type-checker.ts +++ b/packages/labs/compiler/src/lib/type-checker.ts @@ -5,6 +5,7 @@ */ import ts from 'typescript'; +import {isLitTaggedTemplateExpression} from '@lit-labs/analyzer/lib/lit-html/template.js'; const compilerOptions = { target: ts.ScriptTarget.ESNext, @@ -52,129 +53,7 @@ class TypeChecker { * compiled. */ isLitTaggedTemplateExpression(node: ts.TaggedTemplateExpression): boolean { - if (ts.isIdentifier(node.tag)) { - return this.isResolvedIdentifierLitHtmlTemplate(node.tag); - } - if (ts.isPropertyAccessExpression(node.tag)) { - return this.isResolvedPropertyAccessExpressionLitHtmlNamespace(node.tag); - } - return false; - } - - /** - * Resolve the tag function identifier back to an import, returning true if - * the original reference was the `html` export from `lit` or `lit-html`. - * - * This check handles: aliasing and reassigning the import. - * - * ```ts - * import {html as h} from 'lit'; - * h``; - * // isResolvedIdentifierLitHtmlTemplate() returns true - * ``` - * - * ```ts - * import {html} from 'lit-html/static.js'; - * html`false`; - * // isResolvedIdentifierLitHtmlTemplate() returns false - * ``` - * - * @param node a TaggedTemplateExpression tag - */ - private isResolvedIdentifierLitHtmlTemplate(node: ts.Identifier): boolean { - const checker = this.checker; - - const symbol = checker.getSymbolAtLocation(node); - if (!symbol) { - return false; - } - const templateImport = symbol.declarations?.[0]; - if (!templateImport || !ts.isImportSpecifier(templateImport)) { - return false; - } - - // An import specifier has the following structures: - // - // `import { as } from ;` - // `import {} from ;` - // - // This check allows aliasing `html` by ensuring propertyName is `html`. - // Thus `{html as myHtml}` is a valid template that can be compiled. - // Otherwise a compilable template must be a direct import of lit's `html` - // tag function. - if ( - (templateImport.propertyName && - templateImport.propertyName.text !== 'html') || - (!templateImport.propertyName && templateImport.name.text !== 'html') - ) { - return false; - } - const namedImport = templateImport.parent; - if (!ts.isNamedImports(namedImport)) { - return false; - } - const importClause = namedImport.parent; - if (!ts.isImportClause(importClause)) { - return false; - } - const importDeclaration = importClause.parent; - if (!ts.isImportDeclaration(importDeclaration)) { - return false; - } - const specifier = importDeclaration.moduleSpecifier; - if (!ts.isStringLiteral(specifier)) { - return false; - } - return this.isLitTemplateModuleSpecifier(specifier.text); - } - - /** - * Resolve a common pattern of using the `html` identifier of a lit namespace - * import. - * - * E.g.: - * - * ```ts - * import * as identifier from 'lit'; - * identifier.html`

I am compiled!

`; - * ``` - */ - private isResolvedPropertyAccessExpressionLitHtmlNamespace( - node: ts.PropertyAccessExpression - ): boolean { - // Ensure propertyAccessExpression ends with `.html`. - if (ts.isIdentifier(node.name) && node.name.text !== 'html') { - return false; - } - // Expect a namespace preceding `html`, `.html`. - if (!ts.isIdentifier(node.expression)) { - return false; - } - - // Resolve the namespace if it has been aliased. - const checker = this.checker; - const symbol = checker.getSymbolAtLocation(node.expression); - if (!symbol) { - return false; - } - const namespaceImport = symbol.declarations?.[0]; - if (!namespaceImport || !ts.isNamespaceImport(namespaceImport)) { - return false; - } - const importDeclaration = namespaceImport.parent.parent; - const specifier = importDeclaration.moduleSpecifier; - if (!ts.isStringLiteral(specifier)) { - return false; - } - return this.isLitTemplateModuleSpecifier(specifier.text); - } - - private isLitTemplateModuleSpecifier(specifier: string): boolean { - return ( - specifier === 'lit' || - specifier === 'lit-html' || - specifier === 'lit-element' - ); + return isLitTaggedTemplateExpression(node, ts, this.checker); } }