Skip to content

Commit 8fd0b83

Browse files
blutorangeawa-xima
andauthored
Use @import JSDoc tags for TypeScript >= 5.5 (#53)
Co-authored-by: Andre Wachsmuth <[email protected]>
1 parent 52ed07c commit 8fd0b83

File tree

3 files changed

+162
-31
lines changed

3 files changed

+162
-31
lines changed

index.ts

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
2-
Node, Project, ScriptTarget, SyntaxKind, TypeFormatFlags,
2+
Node, Project, ScriptTarget, SyntaxKind, TypeFormatFlags
33
} from "ts-morph";
44

5+
import { versionMajorMinor as tsVersionMajorMinor } from "typescript";
6+
57
import type {
68
CompilerOptions,
79
ClassDeclaration,
@@ -46,6 +48,20 @@ type ObjectProperty = JSDocableNode & TypedNode & (
4648
);
4749
type ClassMemberNode = JSDocableNode & ModifierableNode & ObjectProperty & MethodDeclaration;
4850

51+
interface MajorMinorVersion {
52+
major: number;
53+
minor: number;
54+
}
55+
56+
function parseTsVersion(majorMinor: string): MajorMinorVersion {
57+
const [major, minor] = majorMinor.split(".").map(v => parseInt(v));
58+
return { major, minor };
59+
}
60+
61+
function isTsVersionAtLeast(tsVersion: MajorMinorVersion, major: number, minor: number): boolean {
62+
return tsVersion.major > major || (tsVersion.major === major && tsVersion.minor >= minor);
63+
}
64+
4965
/** Get children for object node */
5066
function getChildProperties(node: Node): ObjectProperty[] {
5167
const properties = node?.getType()?.getProperties();
@@ -102,7 +118,7 @@ function nodeIsOnlyUsedInTypePosition(node: Node & ReferenceFindableNode): boole
102118
}
103119

104120
/** Generate `@typedef` declarations for type imports */
105-
function generateImportDeclarationDocumentation(
121+
function generateImportDeclarationDocumentationViaTypedef(
106122
importDeclaration: ImportDeclaration,
107123
): string {
108124
let typedefs = "";
@@ -131,6 +147,49 @@ function generateImportDeclarationDocumentation(
131147
return typedefs;
132148
}
133149

150+
/** Generate `@import` JSDoc declarations for type imports */
151+
function generateImportDeclarationDocumentationViaImportTag(
152+
importDeclaration: ImportDeclaration,
153+
): string {
154+
const moduleSpecifier = importDeclaration.getModuleSpecifierValue();
155+
const declarationIsTypeOnly = importDeclaration.isTypeOnly();
156+
157+
const imports: { default: string | undefined, named: string[] } = {
158+
default: undefined,
159+
named: [],
160+
};
161+
162+
const defaultImport = importDeclaration.getDefaultImport();
163+
const defaultImportName = defaultImport?.getText();
164+
if (defaultImport) {
165+
if (declarationIsTypeOnly || nodeIsOnlyUsedInTypePosition(defaultImport)) {
166+
imports.default = defaultImportName;
167+
}
168+
}
169+
170+
for (const namedImport of importDeclaration.getNamedImports() ?? []) {
171+
const name = namedImport.getName();
172+
const aliasNode = namedImport.getAliasNode();
173+
const alias = aliasNode?.getText();
174+
if (declarationIsTypeOnly || namedImport.isTypeOnly() || nodeIsOnlyUsedInTypePosition(aliasNode || namedImport.getNameNode())) {
175+
if (alias !== undefined) {
176+
imports.named.push(`${name} as ${alias}`);
177+
} else {
178+
imports.named.push(name);
179+
}
180+
}
181+
}
182+
183+
const importParts: string[] = [];
184+
if (imports.default !== undefined) {
185+
importParts.push(imports.default);
186+
}
187+
if (imports.named.length > 0) {
188+
importParts.push(`{ ${imports.named.join(", ")} }`);
189+
}
190+
return importParts.length > 0 ? `/** @import ${importParts.join(", ")} from '${moduleSpecifier}' */` : "";
191+
}
192+
134193
/**
135194
* Generate `@param` documentation from function parameters for functionNode, storing it in docNode
136195
*/
@@ -564,13 +623,17 @@ function generateNamespaceDocumentation(namespace: ModuleDeclaration, prefix = "
564623
* Generate documentation for a source file
565624
* @param sourceFile The source file to generate documentation for
566625
*/
567-
function generateDocumentationForSourceFile(sourceFile: SourceFile): void {
626+
function generateDocumentationForSourceFile(sourceFile: SourceFile, tsVersion: MajorMinorVersion): void {
568627
sourceFile.getClasses().forEach(generateClassDocumentation);
569628

570629
const namespaceAdditions = sourceFile.getModules()
571630
.map((namespace) => generateNamespaceDocumentation(namespace))
572631
.flat(2);
573632

633+
const generateImportDeclarationDocumentation = isTsVersionAtLeast(tsVersion, 5, 5)
634+
? generateImportDeclarationDocumentationViaImportTag
635+
: generateImportDeclarationDocumentationViaTypedef;
636+
574637
const importDeclarations = sourceFile.getImportDeclarations()
575638
.map((declaration) => generateImportDeclarationDocumentation(declaration).trim())
576639
.join("\n")
@@ -641,8 +704,9 @@ export function transpileProject(tsconfig: string, debug = false): void {
641704
tsConfigFilePath: tsconfig,
642705
});
643706

707+
const tsVersion = parseTsVersion(tsVersionMajorMinor);
644708
const sourceFiles = project.getSourceFiles();
645-
sourceFiles.forEach((sourceFile) => generateDocumentationForSourceFile(sourceFile));
709+
sourceFiles.forEach((sourceFile) => generateDocumentationForSourceFile(sourceFile, tsVersion));
646710

647711
const preEmitDiagnostics = project.getPreEmitDiagnostics();
648712
if (preEmitDiagnostics.length && project.getCompilerOptions().noEmitOnError) {
@@ -674,6 +738,9 @@ export function transpileProject(tsconfig: string, debug = false): void {
674738
* See https://www.typescriptlang.org/tsconfig#compilerOptions
675739
* @param [inMemory=false] Whether to store the file in memory while transpiling
676740
* @param [debug=false] Whether to log errors
741+
* @param [tsVersion=<current>] Major and minor version of TypeScript, used to check for
742+
* certain features such as whether to `@import` or `@typedef` JSDoc tags for imports.
743+
* Defaults to the current TypeScript version.
677744
* @returns Transpiled code (or the original source code if something went wrong)
678745
*/
679746
export function transpileFile(
@@ -683,15 +750,19 @@ export function transpileFile(
683750
compilerOptions = {},
684751
inMemory = false,
685752
debug = false,
753+
tsVersion = tsVersionMajorMinor,
686754
}: {
687755
code: string;
688756
filename?: string;
689757
compilerOptions?: CompilerOptions;
690758
inMemory?: boolean;
691759
debug?: boolean;
760+
tsVersion?: string;
692761
},
693762
): string {
694763
try {
764+
const parsedTsVersion = parseTsVersion(tsVersion);
765+
695766
const project = new Project({
696767
defaultCompilerOptions: {
697768
target: ScriptTarget.ESNext,
@@ -714,7 +785,7 @@ export function transpileFile(
714785
sourceFile = project.createSourceFile(sourceFilename, code);
715786
}
716787

717-
generateDocumentationForSourceFile(sourceFile);
788+
generateDocumentationForSourceFile(sourceFile, parsedTsVersion);
718789

719790
const preEmitDiagnostics = project.getPreEmitDiagnostics();
720791
if (preEmitDiagnostics.length && project.getCompilerOptions().noEmitOnError) {

test/compare.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ const { transpileFile } = require("../index.js");
44
* Compare the transpiled output of the input to the expected output.
55
* @param {string} input The input to transpile.
66
* @param {string} expected The expected output.
7+
* @param {string} tsVersion The TypeScript version to use. Defaults to 4.9.
78
*/
8-
function compareTranspile(input, expected) {
9-
const actual = transpileFile({ code: input });
9+
function compareTranspile(input, expected, tsVersion = "4.9") {
10+
const actual = transpileFile({ code: input, tsVersion });
1011

1112
expect(actual).toBe(expected);
1213
}

test/type-imports.test.js

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,118 @@
11
const compareTranspile = require("./compare.js");
22

33
describe("type-imports", () => {
4-
test("document default type imports", () => {
5-
const input = `
4+
describe("uses @typedef for TS versions < 5.5", () => {
5+
test("document default type imports", () => {
6+
const input = `
67
import type ts from "ts-morph";
78
`;
8-
const expected = `/** @typedef {import('ts-morph')} ts */
9+
const expected = `/** @typedef {import('ts-morph')} ts */
910
export {};
1011
`;
11-
compareTranspile(input, expected);
12-
});
12+
compareTranspile(input, expected, "5.4");
13+
});
1314

14-
test("document named type imports", () => {
15-
const input = `
15+
test("document named type imports", () => {
16+
const input = `
1617
import type { ts } from "ts-morph";
1718
`;
18-
const expected = `/** @typedef {import('ts-morph').ts} ts */
19+
const expected = `/** @typedef {import('ts-morph').ts} ts */
1920
export {};
2021
`;
21-
compareTranspile(input, expected);
22-
});
22+
compareTranspile(input, expected, "5.4");
23+
});
2324

24-
test("document named type imports with alias", () => {
25-
const input = `
25+
test("document named type imports with alias", () => {
26+
const input = `
2627
import type { ts as TypeScript } from "ts-morph";
2728
`;
28-
const expected = `/** @typedef {import('ts-morph').ts} TypeScript */
29+
const expected = `/** @typedef {import('ts-morph').ts} TypeScript */
2930
export {};
3031
`;
31-
compareTranspile(input, expected);
32-
});
32+
compareTranspile(input, expected, "5.4");
33+
});
3334

34-
test("document named type imports but not default value import", () => {
35-
const input = `
35+
test("document named type imports but not default value import", () => {
36+
const input = `
3637
import ts, { type Node } from "ts-morph";
3738
`;
38-
const expected = `/** @typedef {import('ts-morph').Node} Node */
39+
const expected = `/** @typedef {import('ts-morph').Node} Node */
3940
export {};
4041
`;
41-
compareTranspile(input, expected);
42-
});
42+
compareTranspile(input, expected, "5.4");
43+
});
4344

44-
test("document value imports if used only in a type position", () => {
45-
const input = `
45+
test("document value imports if used only in a type position", () => {
46+
const input = `
4647
import { Node } from "ts-morph";
4748
function foo(node: Node) {}
49+
`;
50+
const expected = `/** @typedef {import('ts-morph').Node} Node */
51+
/**
52+
* @param {Node} node
53+
* @returns {void}
54+
*/
55+
function foo(node) { }
56+
export {};
57+
`;
58+
compareTranspile(input, expected, "5.4");
59+
});
60+
});
61+
describe("uses @import for TS versions >= 5.5", () => {
62+
test("document default type imports", () => {
63+
const input = `
64+
import type ts from "ts-morph";
4865
`;
49-
const expected = `/** @typedef {import('ts-morph').Node} Node */
66+
const expected = `/** @import ts from 'ts-morph' */
67+
export {};
68+
`;
69+
compareTranspile(input, expected, "5.5");
70+
});
71+
72+
test("document named type imports", () => {
73+
const input = `
74+
import type { ts } from "ts-morph";
75+
`;
76+
const expected = `/** @import { ts } from 'ts-morph' */
77+
export {};
78+
`;
79+
compareTranspile(input, expected, "5.5");
80+
});
81+
82+
test("document named type imports with alias", () => {
83+
const input = `
84+
import type { ts as TypeScript } from "ts-morph";
85+
`;
86+
const expected = `/** @import { ts as TypeScript } from 'ts-morph' */
87+
export {};
88+
`;
89+
compareTranspile(input, expected, "5.5");
90+
});
91+
92+
test("document named type imports but not default value import", () => {
93+
const input = `
94+
import ts, { type Node } from "ts-morph";
95+
`;
96+
const expected = `/** @import { Node } from 'ts-morph' */
97+
export {};
98+
`;
99+
compareTranspile(input, expected, "5.5");
100+
});
101+
102+
test("document value imports if used only in a type position", () => {
103+
const input = `
104+
import { Node } from "ts-morph";
105+
function foo(node: Node) {}
106+
`;
107+
const expected = `/** @import { Node } from 'ts-morph' */
50108
/**
51109
* @param {Node} node
52110
* @returns {void}
53111
*/
54112
function foo(node) { }
55113
export {};
56114
`;
57-
compareTranspile(input, expected);
115+
compareTranspile(input, expected, "5.5");
116+
});
58117
});
59118
});

0 commit comments

Comments
 (0)