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);
}
}