diff --git a/cem/support-typedef-jsdoc-utils.js b/cem/support-typedef-jsdoc-utils.js index 86a5031e9..5db548e02 100644 --- a/cem/support-typedef-jsdoc-utils.js +++ b/cem/support-typedef-jsdoc-utils.js @@ -136,7 +136,7 @@ function isVarInitWithDoc(node, ts, gatherPrivate = false) { return isVarInit && hasJsDoc && hasTypeIdentifier && (!isFieldPrivate || gatherPrivate); } -export function findTypePath(importTag, rootDir, moduleDir) { +export function findTypePath(importTag, ts, moduleFilePath) { // Remove leading and ending quotes const typeRelativePath = importTag.typeExpression?.type?.argument?.literal.getText().slice(1, -1); @@ -144,10 +144,49 @@ export function findTypePath(importTag, rootDir, moduleDir) { return null; } - const { dir: typeDir, name: typeFileName } = path.parse(typeRelativePath); - const typeToTs = convertToTSExt(typeFileName); + // Use TypeScript's module resolution + const compilerOptions = { + moduleResolution: ts.ModuleResolutionKind.NodeJs, + allowJs: true, + }; - return path.resolve(rootDir, moduleDir, typeDir, typeToTs); + const result = ts.resolveModuleName(typeRelativePath, moduleFilePath, compilerOptions, ts.sys); + + if (result.resolvedModule) { + return result.resolvedModule.resolvedFileName; + } + + return null; +} + +export function extractImportsFromImportTag(importTag, ts, moduleFilePath) { + // @import {Type1, Type2} from './path' + // importTag.importClause.namedBindings.elements contains the types + // importTag.moduleSpecifier.text contains the module path + + const moduleSpecifier = importTag.moduleSpecifier?.text; + const namedBindings = importTag.importClause?.namedBindings; + + if (!moduleSpecifier || !namedBindings) { + return null; + } + + // Use TypeScript's module resolution + const compilerOptions = { + moduleResolution: ts.ModuleResolutionKind.NodeJs, + allowJs: true, + }; + + const result = ts.resolveModuleName(moduleSpecifier, moduleFilePath, compilerOptions, ts.sys); + + if (!result.resolvedModule) { + return null; + } + + const resolvedPath = result.resolvedModule.resolvedFileName; + const types = namedBindings.elements?.map((el) => el.name?.getText()).filter(Boolean) || []; + + return { path: resolvedPath, types }; } export function findSubtypes(ts, node, types, parents) { @@ -275,21 +314,18 @@ export function findPathAndTypesFromImports(ts, filePath, ancestors = null) { const imports = []; let sourceCode = ''; - const parsedPath = path.parse(filePath); - const formattedFilePath = path.resolve(parsedPath.dir, convertToTSExt(filePath)); - - const currentAncestors = ancestors == null ? [formattedFilePath] : ancestors; + const currentAncestors = ancestors == null ? [filePath] : ancestors; // Open file try { - sourceCode = readFileSync(formattedFilePath).toString(); + sourceCode = readFileSync(filePath).toString(); } catch (e) { console.error(e); return []; } // transform to AST - const sourceAst = ts.createSourceFile(formattedFilePath, sourceCode, ts.ScriptTarget.ES2015, true); + const sourceAst = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.ES2015, true); // Gather only imports from the AST const importsDeclaration = sourceAst.statements.filter((node) => node.kind === ts.SyntaxKind.ImportDeclaration); @@ -297,6 +333,12 @@ export function findPathAndTypesFromImports(ts, filePath, ancestors = null) { return []; } + // Use TypeScript's module resolution + const compilerOptions = { + moduleResolution: ts.ModuleResolutionKind.NodeJs, + allowJs: true, + }; + importsDeclaration.forEach((importNode) => { const types = []; const importFile = importNode.moduleSpecifier.text; @@ -305,14 +347,18 @@ export function findPathAndTypesFromImports(ts, filePath, ancestors = null) { return; } - const parsedImportPath = path.parse(importFile); + const result = ts.resolveModuleName(importFile, filePath, compilerOptions, ts.sys); + + if (!result.resolvedModule) { + return; + } - const formattedImportPath = path.join(parsedPath.dir, parsedImportPath.dir, convertToTSExt(parsedImportPath.base)); + const resolvedPath = result.resolvedModule.resolvedFileName; - if (!currentAncestors.includes(formattedImportPath)) { + if (!currentAncestors.includes(resolvedPath)) { importNode.importClause.namedBindings.elements.forEach((nodeType) => types.push(nodeType.getText())); - imports.push({ types, path: formattedImportPath }); - imports.push(...findPathAndTypesFromImports(ts, formattedImportPath, [...currentAncestors, formattedImportPath])); + imports.push({ types, path: resolvedPath }); + imports.push(...findPathAndTypesFromImports(ts, resolvedPath, [...currentAncestors, resolvedPath])); } }); return imports; diff --git a/cem/support-typedef-jsdoc.js b/cem/support-typedef-jsdoc.js index 65f3ba2e7..2f2b42a69 100644 --- a/cem/support-typedef-jsdoc.js +++ b/cem/support-typedef-jsdoc.js @@ -1,8 +1,8 @@ import { readFileSync } from 'fs'; -import path from 'path'; import ts from 'typescript'; import { convertInterface, + extractImportsFromImportTag, findInterfacesFromExtends, findPathAndTypesFromImports, findSubtypes, @@ -10,8 +10,6 @@ import { getTypesFromClass, } from './support-typedef-jsdoc-utils.js'; -const ROOT_DIR = process.cwd(); - export default function supportTypedefJsdoc() { // Map that contains a `type-path` as a key and has its markdown interface as a value. const typesStore = new Map(); @@ -136,36 +134,68 @@ export default function supportTypedefJsdoc() { return; } - // This finds the comment where the imports are located + // This finds the comment where the @typedef imports are located const typeDefNode = statement?.jsDoc?.filter((statement) => statement.tags?.find((tag) => tag.kind === ts.SyntaxKind.JSDocTypedefTag), )?.[0]; - const moduleDir = path.parse(moduleDoc.path).dir; + // Check the jsDoc of the class and find the @typedef imports + typeDefNode?.tags + ?.filter((tag) => tag.kind === ts.SyntaxKind.JSDocTypedefTag) + .forEach((tag) => { + // Extract the path from the @typedef import + const typePath = findTypePath(tag, ts, moduleDoc.path); + + // If an import is not correct, warn the plugin user. + if (typePath == null) { + console.warn(`[${componentName}] - There's a problem with one of your @typedef - ${tag.getText()}`); + process.exitCode = 1; + return; + } + + // Extract the type from the @typedef import + const typeDefDisplay = tag.name.getText(); + + const type = types.find((type) => type === typeDefDisplay); - // Check the jsDoc of the class and find the imports - typeDefNode?.tags?.forEach((tag) => { - // Extract the path from the @typedef import - const typePath = findTypePath(tag, ROOT_DIR, moduleDir); + if (type != null) { + if (!moduleTypeCache.has(typePath)) { + moduleTypeCache.set(typePath, new Set()); + } + moduleTypeCache.get(typePath).add(type); + } + }); + + // Find all @import tags + const importTags = + statement?.jsDoc?.flatMap( + (doc) => doc.tags?.filter((tag) => tag.kind === ts.SyntaxKind.JSDocImportTag) || [], + ) || []; + + // Process @import tags + importTags.forEach((tag) => { + const importData = extractImportsFromImportTag(tag, ts, moduleDoc.path); // If an import is not correct, warn the plugin user. - if (typePath == null) { - console.warn(`[${componentName}] - There's a problem with one of your @typedef - ${tag.getText()}`); + if (importData == null) { + console.warn(`[${componentName}] - There's a problem with one of your @import - ${tag.getText()}`); process.exitCode = 1; return; } - // Extract the type from the @typedef import - const typeDefDisplay = tag.name.getText(); + const { path: typePath, types: importedTypes } = importData; - const type = types.find((type) => type === typeDefDisplay); + // Check which imported types are actually used in the component + importedTypes.forEach((importedType) => { + const type = types.find((type) => type === importedType); - if (type != null) { - if (!moduleTypeCache.has(typePath)) { - moduleTypeCache.set(typePath, new Set()); + if (type != null) { + if (!moduleTypeCache.has(typePath)) { + moduleTypeCache.set(typePath, new Set()); + } + moduleTypeCache.get(typePath).add(type); } - moduleTypeCache.get(typePath).add(type); - } + }); }); // Now that we have the types, and the path of where the types are located diff --git a/test-mocha/cem/fixtures/cc-test-component-with-import.js b/test-mocha/cem/fixtures/cc-test-component-with-import.js new file mode 100644 index 000000000..b765b8272 --- /dev/null +++ b/test-mocha/cem/fixtures/cc-test-component-with-import.js @@ -0,0 +1,26 @@ +import { LitElement } from 'lit'; + +/** + * @import {Foo, Bar} from './cc-test-component.types.js' + * @import {TheInterface} from './cc-test-component.types.js' + * @import {TheType} from './cc-test-component.types.js' + */ + +/** + * Test component using @import syntax instead of @typedef + */ +// eslint-disable-next-line wc/define-tag-after-class-definition +export class CcTestComponentWithImport extends LitElement { + constructor() { + super(); + + /** @type {Foo|Bar} - lorem ipsum. */ + this.union = null; + + /** @type {TheInterface} - lorem ipsum. */ + this.interface = null; + + /** @type {TheType} - lorem ipsum. */ + this.typeDeclaration = null; + } +} diff --git a/test-mocha/cem/support-typedef-jsdoc-utils.test.js b/test-mocha/cem/support-typedef-jsdoc-utils.test.js index 507c81814..dfb9c41a8 100644 --- a/test-mocha/cem/support-typedef-jsdoc-utils.test.js +++ b/test-mocha/cem/support-typedef-jsdoc-utils.test.js @@ -4,6 +4,7 @@ import path from 'path'; import ts from 'typescript'; import { convertInterface, + extractImportsFromImportTag, findCustomType, findInterfacesFromExtends, findPathAndTypesFromImports, @@ -192,26 +193,28 @@ describe('CEM', function () { describe('findPath()', function () { const importsNode = classNode.jsDoc[0].tags; const importLength = importsNode.length; + const moduleFilePath = path.resolve(ROOT_DIR, MODULE_DIR, 'cc-test-component.js'); it('should retrieve the @typedef filePath from the first one in the test file.', function () { - const filePath = findTypePath(importsNode[0], ROOT_DIR, MODULE_DIR); + const filePath = findTypePath(importsNode[0], ts, moduleFilePath); expect(filePath).to.equal(`${ROOT_DIR}/${MODULE_DIR}/cc-test-component.types.d.ts`); }); it('should retrieve the common @typedef filePath located at the end of the test file.', function () { - const filePath = findTypePath(importsNode[importLength - 1], ROOT_DIR, MODULE_DIR); + const filePath = findTypePath(importsNode[importLength - 1], ts, moduleFilePath); expect(filePath).to.equal(`${ROOT_DIR}/${MODULE_DIR}/common.types.d.ts`); }); it('should return null if the filePath is incorrect.', function () { - const filePath = findTypePath(importsNode[importLength - 2], ROOT_DIR, MODULE_DIR); + const filePath = findTypePath(importsNode[importLength - 2], ts, moduleFilePath); expect(filePath).to.equal(null); }); }); describe('findSubtypes()', function () { const importsNode = classNode.jsDoc[0].tags; - const filePath = findTypePath(importsNode[0], ROOT_DIR, MODULE_DIR); + const moduleFilePath = path.resolve(ROOT_DIR, MODULE_DIR, 'cc-test-component.js'); + const filePath = findTypePath(importsNode[0], ts, moduleFilePath); const sourceCode = readFileSync(filePath).toString(); const sourceAst = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.ES2015, true); @@ -277,7 +280,8 @@ describe('CEM', function () { describe('convertInterface()', function () { it('should return the needed interface in the type file for a given interface name.', function () { const importsNode = classNode.jsDoc[0].tags; - const filePath = findTypePath(importsNode[0], ROOT_DIR, MODULE_DIR); + const moduleFilePath = path.resolve(ROOT_DIR, MODULE_DIR, 'cc-test-component.js'); + const filePath = findTypePath(importsNode[0], ts, moduleFilePath); const sourceCode = readFileSync(filePath).toString(); const sourceAst = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.ES2015, true); const interfaceStr = convertInterface(ts, sourceAst, sourceCode, 'TheInterface', filePath); @@ -316,7 +320,7 @@ describe('CEM', function () { describe('findPathAndTypesFromImports()', function () { it('should return an array with all the imports filePath', function () { - const file = 'cc-test-component.types.js'; + const file = 'cc-test-component.types.d.ts'; const pathFile = path.resolve(ROOT_DIR, MODULE_DIR, file); const rootPath = path.resolve(ROOT_DIR, MODULE_DIR); @@ -332,4 +336,54 @@ describe('CEM', function () { ]); }); }); + + describe('extractImportsFromImportTag()', function () { + const filenameWithImport = 'test-mocha/cem/fixtures/cc-test-component-with-import.js'; + const sourceCodeWithImport = fs.readFileSync(filenameWithImport, { encoding: 'utf-8' }); + const sourceAstWithImport = ts.createSourceFile( + filenameWithImport, + sourceCodeWithImport, + ts.ScriptTarget.ES2015, + true, + ); + const classNodeWithImport = sourceAstWithImport.statements.find( + (node) => node.kind === ts.SyntaxKind.ClassDeclaration, + ); + const moduleFilePath = path.resolve(ROOT_DIR, MODULE_DIR, 'cc-test-component-with-import.js'); + + it('should extract module path and types from @import tag', function () { + const importTags = + classNodeWithImport?.jsDoc?.flatMap( + (doc) => doc.tags?.filter((tag) => tag.kind === ts.SyntaxKind.JSDocImportTag) || [], + ) || []; + + const firstImportTag = importTags[0]; + const result = extractImportsFromImportTag(firstImportTag, ts, moduleFilePath); + + expect(result).to.not.be.null; + expect(result.path).to.equal(`${ROOT_DIR}/${MODULE_DIR}/cc-test-component.types.d.ts`); + expect(result.types).to.have.members(['Foo', 'Bar']); + }); + + it('should handle multiple types from same module', function () { + const importTags = + classNodeWithImport?.jsDoc?.flatMap( + (doc) => doc.tags?.filter((tag) => tag.kind === ts.SyntaxKind.JSDocImportTag) || [], + ) || []; + + const secondImportTag = importTags[1]; + const result = extractImportsFromImportTag(secondImportTag, ts, moduleFilePath); + + expect(result).to.not.be.null; + expect(result.path).to.equal(`${ROOT_DIR}/${MODULE_DIR}/cc-test-component.types.d.ts`); + expect(result.types).to.have.members(['TheInterface']); + }); + + it('should return null for invalid import tag', function () { + const invalidTag = { moduleSpecifier: null, importClause: null }; + const result = extractImportsFromImportTag(invalidTag, ts, moduleFilePath); + + expect(result).to.be.null; + }); + }); });