Skip to content

Commit ce16294

Browse files
authored
Merge pull request #1581 from CleverCloud/cem/support-import
2 parents 73917af + ebef10b commit ce16294

File tree

4 files changed

+196
-40
lines changed

4 files changed

+196
-40
lines changed

cem/support-typedef-jsdoc-utils.js

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,57 @@ function isVarInitWithDoc(node, ts, gatherPrivate = false) {
136136
return isVarInit && hasJsDoc && hasTypeIdentifier && (!isFieldPrivate || gatherPrivate);
137137
}
138138

139-
export function findTypePath(importTag, rootDir, moduleDir) {
139+
export function findTypePath(importTag, ts, moduleFilePath) {
140140
// Remove leading and ending quotes
141141
const typeRelativePath = importTag.typeExpression?.type?.argument?.literal.getText().slice(1, -1);
142142

143143
if (typeRelativePath == null) {
144144
return null;
145145
}
146146

147-
const { dir: typeDir, name: typeFileName } = path.parse(typeRelativePath);
148-
const typeToTs = convertToTSExt(typeFileName);
147+
// Use TypeScript's module resolution
148+
const compilerOptions = {
149+
moduleResolution: ts.ModuleResolutionKind.NodeJs,
150+
allowJs: true,
151+
};
149152

150-
return path.resolve(rootDir, moduleDir, typeDir, typeToTs);
153+
const result = ts.resolveModuleName(typeRelativePath, moduleFilePath, compilerOptions, ts.sys);
154+
155+
if (result.resolvedModule) {
156+
return result.resolvedModule.resolvedFileName;
157+
}
158+
159+
return null;
160+
}
161+
162+
export function extractImportsFromImportTag(importTag, ts, moduleFilePath) {
163+
// @import {Type1, Type2} from './path'
164+
// importTag.importClause.namedBindings.elements contains the types
165+
// importTag.moduleSpecifier.text contains the module path
166+
167+
const moduleSpecifier = importTag.moduleSpecifier?.text;
168+
const namedBindings = importTag.importClause?.namedBindings;
169+
170+
if (!moduleSpecifier || !namedBindings) {
171+
return null;
172+
}
173+
174+
// Use TypeScript's module resolution
175+
const compilerOptions = {
176+
moduleResolution: ts.ModuleResolutionKind.NodeJs,
177+
allowJs: true,
178+
};
179+
180+
const result = ts.resolveModuleName(moduleSpecifier, moduleFilePath, compilerOptions, ts.sys);
181+
182+
if (!result.resolvedModule) {
183+
return null;
184+
}
185+
186+
const resolvedPath = result.resolvedModule.resolvedFileName;
187+
const types = namedBindings.elements?.map((el) => el.name?.getText()).filter(Boolean) || [];
188+
189+
return { path: resolvedPath, types };
151190
}
152191

153192
export function findSubtypes(ts, node, types, parents) {
@@ -275,28 +314,31 @@ export function findPathAndTypesFromImports(ts, filePath, ancestors = null) {
275314
const imports = [];
276315
let sourceCode = '';
277316

278-
const parsedPath = path.parse(filePath);
279-
const formattedFilePath = path.resolve(parsedPath.dir, convertToTSExt(filePath));
280-
281-
const currentAncestors = ancestors == null ? [formattedFilePath] : ancestors;
317+
const currentAncestors = ancestors == null ? [filePath] : ancestors;
282318

283319
// Open file
284320
try {
285-
sourceCode = readFileSync(formattedFilePath).toString();
321+
sourceCode = readFileSync(filePath).toString();
286322
} catch (e) {
287323
console.error(e);
288324
return [];
289325
}
290326

291327
// transform to AST
292-
const sourceAst = ts.createSourceFile(formattedFilePath, sourceCode, ts.ScriptTarget.ES2015, true);
328+
const sourceAst = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.ES2015, true);
293329

294330
// Gather only imports from the AST
295331
const importsDeclaration = sourceAst.statements.filter((node) => node.kind === ts.SyntaxKind.ImportDeclaration);
296332
if (importsDeclaration == null) {
297333
return [];
298334
}
299335

336+
// Use TypeScript's module resolution
337+
const compilerOptions = {
338+
moduleResolution: ts.ModuleResolutionKind.NodeJs,
339+
allowJs: true,
340+
};
341+
300342
importsDeclaration.forEach((importNode) => {
301343
const types = [];
302344
const importFile = importNode.moduleSpecifier.text;
@@ -305,14 +347,18 @@ export function findPathAndTypesFromImports(ts, filePath, ancestors = null) {
305347
return;
306348
}
307349

308-
const parsedImportPath = path.parse(importFile);
350+
const result = ts.resolveModuleName(importFile, filePath, compilerOptions, ts.sys);
351+
352+
if (!result.resolvedModule) {
353+
return;
354+
}
309355

310-
const formattedImportPath = path.join(parsedPath.dir, parsedImportPath.dir, convertToTSExt(parsedImportPath.base));
356+
const resolvedPath = result.resolvedModule.resolvedFileName;
311357

312-
if (!currentAncestors.includes(formattedImportPath)) {
358+
if (!currentAncestors.includes(resolvedPath)) {
313359
importNode.importClause.namedBindings.elements.forEach((nodeType) => types.push(nodeType.getText()));
314-
imports.push({ types, path: formattedImportPath });
315-
imports.push(...findPathAndTypesFromImports(ts, formattedImportPath, [...currentAncestors, formattedImportPath]));
360+
imports.push({ types, path: resolvedPath });
361+
imports.push(...findPathAndTypesFromImports(ts, resolvedPath, [...currentAncestors, resolvedPath]));
316362
}
317363
});
318364
return imports;

cem/support-typedef-jsdoc.js

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import { readFileSync } from 'fs';
2-
import path from 'path';
32
import ts from 'typescript';
43
import {
54
convertInterface,
5+
extractImportsFromImportTag,
66
findInterfacesFromExtends,
77
findPathAndTypesFromImports,
88
findSubtypes,
99
findTypePath,
1010
getTypesFromClass,
1111
} from './support-typedef-jsdoc-utils.js';
1212

13-
const ROOT_DIR = process.cwd();
14-
1513
export default function supportTypedefJsdoc() {
1614
// Map that contains a `type-path` as a key and has its markdown interface as a value.
1715
const typesStore = new Map();
@@ -136,36 +134,68 @@ export default function supportTypedefJsdoc() {
136134
return;
137135
}
138136

139-
// This finds the comment where the imports are located
137+
// This finds the comment where the @typedef imports are located
140138
const typeDefNode = statement?.jsDoc?.filter((statement) =>
141139
statement.tags?.find((tag) => tag.kind === ts.SyntaxKind.JSDocTypedefTag),
142140
)?.[0];
143141

144-
const moduleDir = path.parse(moduleDoc.path).dir;
142+
// Check the jsDoc of the class and find the @typedef imports
143+
typeDefNode?.tags
144+
?.filter((tag) => tag.kind === ts.SyntaxKind.JSDocTypedefTag)
145+
.forEach((tag) => {
146+
// Extract the path from the @typedef import
147+
const typePath = findTypePath(tag, ts, moduleDoc.path);
148+
149+
// If an import is not correct, warn the plugin user.
150+
if (typePath == null) {
151+
console.warn(`[${componentName}] - There's a problem with one of your @typedef - ${tag.getText()}`);
152+
process.exitCode = 1;
153+
return;
154+
}
155+
156+
// Extract the type from the @typedef import
157+
const typeDefDisplay = tag.name.getText();
158+
159+
const type = types.find((type) => type === typeDefDisplay);
145160

146-
// Check the jsDoc of the class and find the imports
147-
typeDefNode?.tags?.forEach((tag) => {
148-
// Extract the path from the @typedef import
149-
const typePath = findTypePath(tag, ROOT_DIR, moduleDir);
161+
if (type != null) {
162+
if (!moduleTypeCache.has(typePath)) {
163+
moduleTypeCache.set(typePath, new Set());
164+
}
165+
moduleTypeCache.get(typePath).add(type);
166+
}
167+
});
168+
169+
// Find all @import tags
170+
const importTags =
171+
statement?.jsDoc?.flatMap(
172+
(doc) => doc.tags?.filter((tag) => tag.kind === ts.SyntaxKind.JSDocImportTag) || [],
173+
) || [];
174+
175+
// Process @import tags
176+
importTags.forEach((tag) => {
177+
const importData = extractImportsFromImportTag(tag, ts, moduleDoc.path);
150178

151179
// If an import is not correct, warn the plugin user.
152-
if (typePath == null) {
153-
console.warn(`[${componentName}] - There's a problem with one of your @typedef - ${tag.getText()}`);
180+
if (importData == null) {
181+
console.warn(`[${componentName}] - There's a problem with one of your @import - ${tag.getText()}`);
154182
process.exitCode = 1;
155183
return;
156184
}
157185

158-
// Extract the type from the @typedef import
159-
const typeDefDisplay = tag.name.getText();
186+
const { path: typePath, types: importedTypes } = importData;
160187

161-
const type = types.find((type) => type === typeDefDisplay);
188+
// Check which imported types are actually used in the component
189+
importedTypes.forEach((importedType) => {
190+
const type = types.find((type) => type === importedType);
162191

163-
if (type != null) {
164-
if (!moduleTypeCache.has(typePath)) {
165-
moduleTypeCache.set(typePath, new Set());
192+
if (type != null) {
193+
if (!moduleTypeCache.has(typePath)) {
194+
moduleTypeCache.set(typePath, new Set());
195+
}
196+
moduleTypeCache.get(typePath).add(type);
166197
}
167-
moduleTypeCache.get(typePath).add(type);
168-
}
198+
});
169199
});
170200

171201
// Now that we have the types, and the path of where the types are located
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { LitElement } from 'lit';
2+
3+
/**
4+
* @import {Foo, Bar} from './cc-test-component.types.js'
5+
* @import {TheInterface} from './cc-test-component.types.js'
6+
* @import {TheType} from './cc-test-component.types.js'
7+
*/
8+
9+
/**
10+
* Test component using @import syntax instead of @typedef
11+
*/
12+
// eslint-disable-next-line wc/define-tag-after-class-definition
13+
export class CcTestComponentWithImport extends LitElement {
14+
constructor() {
15+
super();
16+
17+
/** @type {Foo|Bar} - lorem ipsum. */
18+
this.union = null;
19+
20+
/** @type {TheInterface} - lorem ipsum. */
21+
this.interface = null;
22+
23+
/** @type {TheType} - lorem ipsum. */
24+
this.typeDeclaration = null;
25+
}
26+
}

test-mocha/cem/support-typedef-jsdoc-utils.test.js

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from 'path';
44
import ts from 'typescript';
55
import {
66
convertInterface,
7+
extractImportsFromImportTag,
78
findCustomType,
89
findInterfacesFromExtends,
910
findPathAndTypesFromImports,
@@ -192,26 +193,28 @@ describe('CEM', function () {
192193
describe('findPath()', function () {
193194
const importsNode = classNode.jsDoc[0].tags;
194195
const importLength = importsNode.length;
196+
const moduleFilePath = path.resolve(ROOT_DIR, MODULE_DIR, 'cc-test-component.js');
195197

196198
it('should retrieve the @typedef filePath from the first one in the test file.', function () {
197-
const filePath = findTypePath(importsNode[0], ROOT_DIR, MODULE_DIR);
199+
const filePath = findTypePath(importsNode[0], ts, moduleFilePath);
198200
expect(filePath).to.equal(`${ROOT_DIR}/${MODULE_DIR}/cc-test-component.types.d.ts`);
199201
});
200202

201203
it('should retrieve the common @typedef filePath located at the end of the test file.', function () {
202-
const filePath = findTypePath(importsNode[importLength - 1], ROOT_DIR, MODULE_DIR);
204+
const filePath = findTypePath(importsNode[importLength - 1], ts, moduleFilePath);
203205
expect(filePath).to.equal(`${ROOT_DIR}/${MODULE_DIR}/common.types.d.ts`);
204206
});
205207

206208
it('should return null if the filePath is incorrect.', function () {
207-
const filePath = findTypePath(importsNode[importLength - 2], ROOT_DIR, MODULE_DIR);
209+
const filePath = findTypePath(importsNode[importLength - 2], ts, moduleFilePath);
208210
expect(filePath).to.equal(null);
209211
});
210212
});
211213

212214
describe('findSubtypes()', function () {
213215
const importsNode = classNode.jsDoc[0].tags;
214-
const filePath = findTypePath(importsNode[0], ROOT_DIR, MODULE_DIR);
216+
const moduleFilePath = path.resolve(ROOT_DIR, MODULE_DIR, 'cc-test-component.js');
217+
const filePath = findTypePath(importsNode[0], ts, moduleFilePath);
215218
const sourceCode = readFileSync(filePath).toString();
216219
const sourceAst = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.ES2015, true);
217220

@@ -277,7 +280,8 @@ describe('CEM', function () {
277280
describe('convertInterface()', function () {
278281
it('should return the needed interface in the type file for a given interface name.', function () {
279282
const importsNode = classNode.jsDoc[0].tags;
280-
const filePath = findTypePath(importsNode[0], ROOT_DIR, MODULE_DIR);
283+
const moduleFilePath = path.resolve(ROOT_DIR, MODULE_DIR, 'cc-test-component.js');
284+
const filePath = findTypePath(importsNode[0], ts, moduleFilePath);
281285
const sourceCode = readFileSync(filePath).toString();
282286
const sourceAst = ts.createSourceFile(filePath, sourceCode, ts.ScriptTarget.ES2015, true);
283287
const interfaceStr = convertInterface(ts, sourceAst, sourceCode, 'TheInterface', filePath);
@@ -316,7 +320,7 @@ describe('CEM', function () {
316320

317321
describe('findPathAndTypesFromImports()', function () {
318322
it('should return an array with all the imports filePath', function () {
319-
const file = 'cc-test-component.types.js';
323+
const file = 'cc-test-component.types.d.ts';
320324
const pathFile = path.resolve(ROOT_DIR, MODULE_DIR, file);
321325
const rootPath = path.resolve(ROOT_DIR, MODULE_DIR);
322326

@@ -332,4 +336,54 @@ describe('CEM', function () {
332336
]);
333337
});
334338
});
339+
340+
describe('extractImportsFromImportTag()', function () {
341+
const filenameWithImport = 'test-mocha/cem/fixtures/cc-test-component-with-import.js';
342+
const sourceCodeWithImport = fs.readFileSync(filenameWithImport, { encoding: 'utf-8' });
343+
const sourceAstWithImport = ts.createSourceFile(
344+
filenameWithImport,
345+
sourceCodeWithImport,
346+
ts.ScriptTarget.ES2015,
347+
true,
348+
);
349+
const classNodeWithImport = sourceAstWithImport.statements.find(
350+
(node) => node.kind === ts.SyntaxKind.ClassDeclaration,
351+
);
352+
const moduleFilePath = path.resolve(ROOT_DIR, MODULE_DIR, 'cc-test-component-with-import.js');
353+
354+
it('should extract module path and types from @import tag', function () {
355+
const importTags =
356+
classNodeWithImport?.jsDoc?.flatMap(
357+
(doc) => doc.tags?.filter((tag) => tag.kind === ts.SyntaxKind.JSDocImportTag) || [],
358+
) || [];
359+
360+
const firstImportTag = importTags[0];
361+
const result = extractImportsFromImportTag(firstImportTag, ts, moduleFilePath);
362+
363+
expect(result).to.not.be.null;
364+
expect(result.path).to.equal(`${ROOT_DIR}/${MODULE_DIR}/cc-test-component.types.d.ts`);
365+
expect(result.types).to.have.members(['Foo', 'Bar']);
366+
});
367+
368+
it('should handle multiple types from same module', function () {
369+
const importTags =
370+
classNodeWithImport?.jsDoc?.flatMap(
371+
(doc) => doc.tags?.filter((tag) => tag.kind === ts.SyntaxKind.JSDocImportTag) || [],
372+
) || [];
373+
374+
const secondImportTag = importTags[1];
375+
const result = extractImportsFromImportTag(secondImportTag, ts, moduleFilePath);
376+
377+
expect(result).to.not.be.null;
378+
expect(result.path).to.equal(`${ROOT_DIR}/${MODULE_DIR}/cc-test-component.types.d.ts`);
379+
expect(result.types).to.have.members(['TheInterface']);
380+
});
381+
382+
it('should return null for invalid import tag', function () {
383+
const invalidTag = { moduleSpecifier: null, importClause: null };
384+
const result = extractImportsFromImportTag(invalidTag, ts, moduleFilePath);
385+
386+
expect(result).to.be.null;
387+
});
388+
});
335389
});

0 commit comments

Comments
 (0)