diff --git a/index.ts b/index.ts index 7f59a22..1166a1b 100644 --- a/index.ts +++ b/index.ts @@ -1,7 +1,9 @@ import { - Node, Project, ScriptTarget, SyntaxKind, TypeFormatFlags, + Node, Project, ScriptTarget, SyntaxKind, TypeFormatFlags } from "ts-morph"; +import { versionMajorMinor as tsVersionMajorMinor } from "typescript"; + import type { CompilerOptions, ClassDeclaration, @@ -46,6 +48,20 @@ type ObjectProperty = JSDocableNode & TypedNode & ( ); type ClassMemberNode = JSDocableNode & ModifierableNode & ObjectProperty & MethodDeclaration; +interface MajorMinorVersion { + major: number; + minor: number; +} + +function parseTsVersion(majorMinor: string): MajorMinorVersion { + const [major, minor] = majorMinor.split(".").map(v => parseInt(v)); + return { major, minor }; +} + +function isTsVersionAtLeast(tsVersion: MajorMinorVersion, major: number, minor: number): boolean { + return tsVersion.major > major || (tsVersion.major === major && tsVersion.minor >= minor); +} + /** Get children for object node */ function getChildProperties(node: Node): ObjectProperty[] { const properties = node?.getType()?.getProperties(); @@ -102,7 +118,7 @@ function nodeIsOnlyUsedInTypePosition(node: Node & ReferenceFindableNode): boole } /** Generate `@typedef` declarations for type imports */ -function generateImportDeclarationDocumentation( +function generateImportDeclarationDocumentationViaTypedef( importDeclaration: ImportDeclaration, ): string { let typedefs = ""; @@ -131,6 +147,49 @@ function generateImportDeclarationDocumentation( return typedefs; } +/** Generate `@import` JSDoc declarations for type imports */ +function generateImportDeclarationDocumentationViaImportTag( + importDeclaration: ImportDeclaration, +): string { + const moduleSpecifier = importDeclaration.getModuleSpecifierValue(); + const declarationIsTypeOnly = importDeclaration.isTypeOnly(); + + const imports: { default: string | undefined, named: string[] } = { + default: undefined, + named: [], + }; + + const defaultImport = importDeclaration.getDefaultImport(); + const defaultImportName = defaultImport?.getText(); + if (defaultImport) { + if (declarationIsTypeOnly || nodeIsOnlyUsedInTypePosition(defaultImport)) { + imports.default = defaultImportName; + } + } + + for (const namedImport of importDeclaration.getNamedImports() ?? []) { + const name = namedImport.getName(); + const aliasNode = namedImport.getAliasNode(); + const alias = aliasNode?.getText(); + if (declarationIsTypeOnly || namedImport.isTypeOnly() || nodeIsOnlyUsedInTypePosition(aliasNode || namedImport.getNameNode())) { + if (alias !== undefined) { + imports.named.push(`${name} as ${alias}`); + } else { + imports.named.push(name); + } + } + } + + const importParts: string[] = []; + if (imports.default !== undefined) { + importParts.push(imports.default); + } + if (imports.named.length > 0) { + importParts.push(`{ ${imports.named.join(", ")} }`); + } + return importParts.length > 0 ? `/** @import ${importParts.join(", ")} from '${moduleSpecifier}' */` : ""; +} + /** * Generate `@param` documentation from function parameters for functionNode, storing it in docNode */ @@ -564,13 +623,17 @@ function generateNamespaceDocumentation(namespace: ModuleDeclaration, prefix = " * Generate documentation for a source file * @param sourceFile The source file to generate documentation for */ -function generateDocumentationForSourceFile(sourceFile: SourceFile): void { +function generateDocumentationForSourceFile(sourceFile: SourceFile, tsVersion: MajorMinorVersion): void { sourceFile.getClasses().forEach(generateClassDocumentation); const namespaceAdditions = sourceFile.getModules() .map((namespace) => generateNamespaceDocumentation(namespace)) .flat(2); + const generateImportDeclarationDocumentation = isTsVersionAtLeast(tsVersion, 5, 5) + ? generateImportDeclarationDocumentationViaImportTag + : generateImportDeclarationDocumentationViaTypedef; + const importDeclarations = sourceFile.getImportDeclarations() .map((declaration) => generateImportDeclarationDocumentation(declaration).trim()) .join("\n") @@ -641,8 +704,9 @@ export function transpileProject(tsconfig: string, debug = false): void { tsConfigFilePath: tsconfig, }); + const tsVersion = parseTsVersion(tsVersionMajorMinor); const sourceFiles = project.getSourceFiles(); - sourceFiles.forEach((sourceFile) => generateDocumentationForSourceFile(sourceFile)); + sourceFiles.forEach((sourceFile) => generateDocumentationForSourceFile(sourceFile, tsVersion)); const preEmitDiagnostics = project.getPreEmitDiagnostics(); if (preEmitDiagnostics.length && project.getCompilerOptions().noEmitOnError) { @@ -674,6 +738,9 @@ export function transpileProject(tsconfig: string, debug = false): void { * See https://www.typescriptlang.org/tsconfig#compilerOptions * @param [inMemory=false] Whether to store the file in memory while transpiling * @param [debug=false] Whether to log errors + * @param [tsVersion=] Major and minor version of TypeScript, used to check for + * certain features such as whether to `@import` or `@typedef` JSDoc tags for imports. + * Defaults to the current TypeScript version. * @returns Transpiled code (or the original source code if something went wrong) */ export function transpileFile( @@ -683,15 +750,19 @@ export function transpileFile( compilerOptions = {}, inMemory = false, debug = false, + tsVersion = tsVersionMajorMinor, }: { code: string; filename?: string; compilerOptions?: CompilerOptions; inMemory?: boolean; debug?: boolean; + tsVersion?: string; }, ): string { try { + const parsedTsVersion = parseTsVersion(tsVersion); + const project = new Project({ defaultCompilerOptions: { target: ScriptTarget.ESNext, @@ -714,7 +785,7 @@ export function transpileFile( sourceFile = project.createSourceFile(sourceFilename, code); } - generateDocumentationForSourceFile(sourceFile); + generateDocumentationForSourceFile(sourceFile, parsedTsVersion); const preEmitDiagnostics = project.getPreEmitDiagnostics(); if (preEmitDiagnostics.length && project.getCompilerOptions().noEmitOnError) { diff --git a/test/compare.js b/test/compare.js index e7995a4..5dcfe4f 100644 --- a/test/compare.js +++ b/test/compare.js @@ -4,9 +4,10 @@ const { transpileFile } = require("../index.js"); * Compare the transpiled output of the input to the expected output. * @param {string} input The input to transpile. * @param {string} expected The expected output. + * @param {string} tsVersion The TypeScript version to use. Defaults to 4.9. */ -function compareTranspile(input, expected) { - const actual = transpileFile({ code: input }); +function compareTranspile(input, expected, tsVersion = "4.9") { + const actual = transpileFile({ code: input, tsVersion }); expect(actual).toBe(expected); } diff --git a/test/type-imports.test.js b/test/type-imports.test.js index b74e700..863b61f 100644 --- a/test/type-imports.test.js +++ b/test/type-imports.test.js @@ -1,52 +1,110 @@ const compareTranspile = require("./compare.js"); describe("type-imports", () => { - test("document default type imports", () => { - const input = ` + describe("uses @typedef for TS versions < 5.5", () => { + test("document default type imports", () => { + const input = ` import type ts from "ts-morph"; `; - const expected = `/** @typedef {import('ts-morph')} ts */ + const expected = `/** @typedef {import('ts-morph')} ts */ export {}; `; - compareTranspile(input, expected); - }); + compareTranspile(input, expected, "5.4"); + }); - test("document named type imports", () => { - const input = ` + test("document named type imports", () => { + const input = ` import type { ts } from "ts-morph"; `; - const expected = `/** @typedef {import('ts-morph').ts} ts */ + const expected = `/** @typedef {import('ts-morph').ts} ts */ export {}; `; - compareTranspile(input, expected); - }); + compareTranspile(input, expected, "5.4"); + }); - test("document named type imports with alias", () => { - const input = ` + test("document named type imports with alias", () => { + const input = ` import type { ts as TypeScript } from "ts-morph"; `; - const expected = `/** @typedef {import('ts-morph').ts} TypeScript */ + const expected = `/** @typedef {import('ts-morph').ts} TypeScript */ export {}; `; - compareTranspile(input, expected); - }); + compareTranspile(input, expected, "5.4"); + }); - test("document named type imports but not default value import", () => { - const input = ` + test("document named type imports but not default value import", () => { + const input = ` import ts, { type Node } from "ts-morph"; `; - const expected = `/** @typedef {import('ts-morph').Node} Node */ + const expected = `/** @typedef {import('ts-morph').Node} Node */ export {}; `; - compareTranspile(input, expected); - }); + compareTranspile(input, expected, "5.4"); + }); - test("document value imports if used only in a type position", () => { - const input = ` + test("document value imports if used only in a type position", () => { + const input = ` import { Node } from "ts-morph"; function foo(node: Node) {} + `; + const expected = `/** @typedef {import('ts-morph').Node} Node */ +/** + * @param {Node} node + * @returns {void} + */ +function foo(node) { } +export {}; +`; + compareTranspile(input, expected, "5.4"); + }); + }); + describe("uses @import for TS versions >= 5.5", () => { + test("document default type imports", () => { + const input = ` +import type ts from "ts-morph"; `; - const expected = `/** @typedef {import('ts-morph').Node} Node */ + const expected = `/** @import ts from 'ts-morph' */ +export {}; +`; + compareTranspile(input, expected, "5.5"); + }); + + test("document named type imports", () => { + const input = ` +import type { ts } from "ts-morph"; +`; + const expected = `/** @import { ts } from 'ts-morph' */ +export {}; +`; + compareTranspile(input, expected, "5.5"); + }); + + test("document named type imports with alias", () => { + const input = ` +import type { ts as TypeScript } from "ts-morph"; +`; + const expected = `/** @import { ts as TypeScript } from 'ts-morph' */ +export {}; +`; + compareTranspile(input, expected, "5.5"); + }); + + test("document named type imports but not default value import", () => { + const input = ` +import ts, { type Node } from "ts-morph"; +`; + const expected = `/** @import { Node } from 'ts-morph' */ +export {}; +`; + compareTranspile(input, expected, "5.5"); + }); + + test("document value imports if used only in a type position", () => { + const input = ` +import { Node } from "ts-morph"; +function foo(node: Node) {} + `; + const expected = `/** @import { Node } from 'ts-morph' */ /** * @param {Node} node * @returns {void} @@ -54,6 +112,7 @@ function foo(node: Node) {} function foo(node) { } export {}; `; - compareTranspile(input, expected); + compareTranspile(input, expected, "5.5"); + }); }); });