Skip to content

Commit

Permalink
[labs/analyzer] Add template utils to analyzer and use them in the co…
Browse files Browse the repository at this point in the history
…mpiler (#4261)
  • Loading branch information
justinfagnani committed Feb 1, 2024
1 parent c51bc18 commit 1b17a36
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 125 deletions.
5 changes: 5 additions & 0 deletions .changeset/nice-steaks-hope.md
@@ -0,0 +1,5 @@
---
'@lit-labs/analyzer': minor
---

Add lib/lit-html/template.js module with initial template utilities.
3 changes: 2 additions & 1 deletion packages/labs/analyzer/package.json
Expand Up @@ -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",
Expand Down
165 changes: 165 additions & 0 deletions 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`<p>I am compiled!</p>`;
* ```
*/
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`, `<namespace>.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(<h ast node>) returns true
* ```
*
* ```ts
* import {html} from 'lit-html/static.js';
* html`false`;
* // isResolvedIdentifierLitHtmlTemplate(<html ast node>) 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 {<propertyName> as <name>} from <moduleSpecifier>;`
// `import {<name>} from <moduleSpecifier>;`
//
// 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);
};
56 changes: 56 additions & 0 deletions 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<AnalyzerTestContext>(`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();
}
2 changes: 2 additions & 0 deletions packages/labs/analyzer/src/test/server/utils.ts
Expand Up @@ -206,6 +206,7 @@ export class InMemoryAnalyzer extends Analyzer {

export interface AnalyzerTestContext {
analyzer: Analyzer;
typescript: typeof ts;
packagePath: AbsolutePath;
getModule: (name: string) => Module;
}
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/labs/compiler/package.json
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down

0 comments on commit 1b17a36

Please sign in to comment.