Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 61 additions & 15 deletions cem/support-typedef-jsdoc-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,57 @@ 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);

if (typeRelativePath == null) {
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) {
Expand Down Expand Up @@ -275,28 +314,31 @@ 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);
if (importsDeclaration == 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;
Expand All @@ -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;
Expand Down
68 changes: 49 additions & 19 deletions cem/support-typedef-jsdoc.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { readFileSync } from 'fs';
import path from 'path';
import ts from 'typescript';
import {
convertInterface,
extractImportsFromImportTag,
findInterfacesFromExtends,
findPathAndTypesFromImports,
findSubtypes,
findTypePath,
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();
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions test-mocha/cem/fixtures/cc-test-component-with-import.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
66 changes: 60 additions & 6 deletions test-mocha/cem/support-typedef-jsdoc-utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'path';
import ts from 'typescript';
import {
convertInterface,
extractImportsFromImportTag,
findCustomType,
findInterfacesFromExtends,
findPathAndTypesFromImports,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand All @@ -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;
});
});
});