diff --git a/src/cli.js b/src/cli.js index 2263018..119805e 100755 --- a/src/cli.js +++ b/src/cli.js @@ -25,62 +25,71 @@ const program = sade('dts-buddy [bundle]', true) .option('--project, -p', 'The location of your TypeScript configuration', 'tsconfig.json') .option('--module, -m', 'Each entry point, as : (can be used multiple times)') .option('--debug', 'Directory to emit .d.ts files for debugging') - .action(async (output, opts) => { - if (!fs.existsSync('package.json')) { - exit('No package.json found'); - } + .action( + /** + * @param {string} output + * @param {{ project: string; module?: string | string[]; debug?: string; }} opts + */ + async (output, opts) => { + /** @type {Record} */ + const modules = {}; - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - if (!output) output = pkg.types ?? 'index.d.ts'; + if (opts.module) { + const entries = Array.isArray(opts.module) ? opts.module : [opts.module]; + for (const entry of entries) { + const [id, path] = entry.split(':'); + if (!id || !path) { + exit(`Invalid module entry: ${entry}`); + } + modules[id] = path; + } + } else { + if (!fs.existsSync('package.json')) { + exit('No package.json found'); + } - /** @type {Record} */ - const modules = {}; + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - if (opts.module) { - const entries = Array.isArray(opts.module) ? opts.module : [opts.module]; - for (const entry of entries) { - const [id, path] = entry.split(':'); - if (!id || !path) { - exit(`Invalid module entry: ${entry}`); + if (!output) output = pkg.types; + + if (!pkg.exports) { + exit('No "exports" field in package.json'); } - modules[id] = path; - } - } else { - if (!pkg.exports) { - exit('No "exports" field in package.json'); - } - for (const [key, value] of Object.entries(pkg.exports)) { - if (key[0] !== '.') continue; + for (const [key, value] of Object.entries(pkg.exports)) { + if (key[0] !== '.') continue; - const entry = value.import ?? value.default; - if (typeof entry === 'string') { - modules[pkg.name + key.slice(1)] = entry; - } else { - warn(`Skipping pkg.exports["${key}"] — expected an "import" or "default" string`); + const entry = value.import ?? value.default; + if (typeof entry === 'string') { + modules[pkg.name + key.slice(1)] = entry; + } else { + warn(`Skipping pkg.exports["${key}"] — expected an "import" or "default" string`); + } } - } - if (Object.keys(modules).length === 0) { - if (typeof pkg.exports === 'string') { - modules[pkg.name] = pkg.exports; - } else if (pkg.exports['import'] || pkg.exports['default']) { - modules[pkg.name] = pkg.exports['import'] ?? pkg.exports['default']; - } else { - exit('No entry points found in pkg.exports'); + if (Object.keys(modules).length === 0) { + if (typeof pkg.exports === 'string') { + modules[pkg.name] = pkg.exports; + } else if (pkg.exports['import'] || pkg.exports['default']) { + modules[pkg.name] = pkg.exports['import'] ?? pkg.exports['default']; + } else { + exit('No entry points found in pkg.exports'); + } } } - } - await createBundle({ - output, - modules, - project: opts.project, - debug: opts.debug - }); + if (!output) output = 'index.d.ts'; - const relative = path.relative(process.cwd(), output); - console.error(`Wrote ${c.bold().cyan(relative)} and ${c.bold().cyan(relative + '.map')}\n`); - }); + await createBundle({ + output, + modules, + project: opts.project, + debug: opts.debug + }); + + const relative = path.relative(process.cwd(), output); + console.error(`Wrote ${c.bold().cyan(relative)} and ${c.bold().cyan(relative + '.map')}\n`); + } + ); program.parse(process.argv); diff --git a/src/create-module-declaration.js b/src/create-module-declaration.js index 8cbefe7..a56f3dd 100644 --- a/src/create-module-declaration.js +++ b/src/create-module-declaration.js @@ -204,20 +204,30 @@ export function create_module_declaration(id, entry, created, resolve, options) for (const id in external_imports) { const specifiers = []; + let ts_ignore; + for (const name in external_imports[id]) { const declaration = external_imports[id][name]; if (declaration.included) { + ts_ignore ??= declaration.ts_ignore; specifiers.push(name === declaration.alias ? name : `${name} as ${declaration.alias}`); } } if (specifiers.length > 0) { + if (ts_ignore !== undefined) { + content += `\n\t/** @ts-ignore ${ts_ignore} */`; + } content += `\n\timport type { ${specifiers.join(', ')} } from '${id}';`; } } for (const id in external_import_alls) { for (const name in external_import_alls[id]) { + const declaration = external_import_alls[id][name]; + if (declaration.ts_ignore !== undefined) { + content += `\n\t/** @ts-ignore ${declaration.ts_ignore} */`; + } content += `\n\timport * as ${name} from '${id}';`; // TODO could this have been aliased? } } @@ -559,6 +569,7 @@ function create_external_declaration(binding, alias) { external: true, included: false, dependencies: [], - preferred_alias: alias + preferred_alias: alias, + ts_ignore: binding.ts_ignore }; } diff --git a/src/index.js b/src/index.js index 3697adc..7d5c011 100644 --- a/src/index.js +++ b/src/index.js @@ -198,7 +198,7 @@ export async function createBundle(options) { /** @type {Set} */ const ambient_modules = new Set(); - /** @type {Set} */ + /** @type {Set<{ id: string, ts_ignore: string | undefined }>} */ const external_ambient_modules = new Set(); let first = true; @@ -237,7 +237,7 @@ export async function createBundle(options) { all_mappings.set(id, mappings); for (const dep of ambient) { if (dep.external) { - external_ambient_modules.add(dep.id); + external_ambient_modules.add({ id: dep.id, ts_ignore: dep.ts_ignore }); } else { ambient_modules.add(dep.id); } @@ -268,7 +268,14 @@ export async function createBundle(options) { if (external_ambient_modules.size > 0) { const imports = Array.from(external_ambient_modules) - .map((id) => `/// `) + .map(({ id, ts_ignore }) => { + let content = ''; + if (ts_ignore !== undefined) { + content += `/** @ts-ignore ${ts_ignore} */\n`; + } + content += `/// `; + return content; + }) .join('\n'); types = `${imports}\n\n${types}`; diff --git a/src/types.d.ts b/src/types.d.ts index ce66fd4..99f697d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -27,17 +27,20 @@ interface Declaration { * Only applies to default imports from external modules */ preferred_alias: string; + ts_ignore?: string; } interface Binding { id: string; external: boolean; name: string; + ts_ignore?: string; } interface ModuleReference { id: string; external: boolean; + ts_ignore?: string; } interface Module { diff --git a/src/utils.js b/src/utils.js index 17f85e2..7c9fe42 100644 --- a/src/utils.js +++ b/src/utils.js @@ -93,6 +93,12 @@ export function clean_jsdoc(node, code) { let should_keep = !!jsDoc.comment; jsDoc.tags?.forEach((tag) => { + // @ts-ignore if we've set this ourselves, it means we need to remove the jsdoc + if (tag.remove) { + code.remove(tag.pos, tag.end); + return; + } + const type = /** @type {string} */ (tag.tagName.escapedText); // @ts-ignore @@ -231,6 +237,27 @@ export function get_dts(file, created, resolve, options) { /** @param {ts.Node} node */ function scan(node) { + // TS >=5.5 tries to inline JSDoc import declarations, but if TS is older or + // if the module can't be resolved, the JSDoc will be preserved. + // However, the JSDoc import declaration doesn't work in a .ts file, so we + // need to convert it to a regular import. + const jsdoc = get_jsdoc(node); + let cursor = 0; + jsdoc?.forEach((doc, i) => { + for (const tag of doc.tags ?? []) { + if (!ts.isJSDocImportTag(tag)) continue; + + const import_statement_jsdoc = jsdoc.slice(cursor, i); + const import_statement_node = /** @type {ts.Node} */ ({ + ...tag, + kind: 272, + jsDoc: import_statement_jsdoc.length ? import_statement_jsdoc : undefined + }); + scan(import_statement_node); + cursor = i + 1; + } + }); + // follow imports if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { @@ -245,6 +272,22 @@ export function get_dts(file, created, resolve, options) { } if (ts.isImportDeclaration(node)) { + /** @type {string | undefined} */ + let ts_ignore; + + // We can only detect the jsdoc comment if it is above the start of the declaration. + // It doesn't work if it is inserted in the middle of a statement. + const jsdoc = get_jsdoc(node); + for (const doc of jsdoc ?? []) { + for (const tag of doc.tags ?? []) { + if (tag.tagName.escapedText === 'ts-ignore') { + ts_ignore = tag.comment?.toString() ?? ''; + // @ts-ignore we want to cleanup this comment later because we're going to add it when we add the imports ourselves + tag.remove = true; + } + } + } + if (node.importClause) { // `import foo` if (node.importClause.name) { @@ -252,7 +295,8 @@ export function get_dts(file, created, resolve, options) { module.imports.set(name, { id, external, - name: 'default' + name: 'default', + ts_ignore }); } else if (node.importClause.namedBindings) { // `import * as foo` @@ -261,7 +305,8 @@ export function get_dts(file, created, resolve, options) { module.import_all.set(name, { id, external, - name + name, + ts_ignore }); } @@ -273,14 +318,15 @@ export function get_dts(file, created, resolve, options) { module.imports.set(local, { id, external, - name: specifier.propertyName?.getText(module.ast) ?? local + name: specifier.propertyName?.getText(module.ast) ?? local, + ts_ignore }); }); } } } else { // assume this is an ambient module - module.ambient_imports.push({ id, external }); + module.ambient_imports.push({ id, external, ts_ignore }); } } @@ -486,6 +532,8 @@ export function get_dts(file, created, resolve, options) { ast.statements.forEach(scan); + console.log(module.dts); + for (const name of module.references) { if (!module.declarations.has(name) && !module.imports.has(name)) { module.globals.push(name); diff --git a/test/samples/convert-invalid-jsdoc-imports/input/index.js b/test/samples/convert-invalid-jsdoc-imports/input/index.js new file mode 100644 index 0000000..f9fc530 --- /dev/null +++ b/test/samples/convert-invalid-jsdoc-imports/input/index.js @@ -0,0 +1,9 @@ +/** @ts-ignore this type is generated */ +/** @import { TypeA } from 'generated' */ + +/** + * @returns {TypeA} + */ +export function a() { + return 1; +} diff --git a/test/samples/convert-invalid-jsdoc-imports/output/index.d.ts b/test/samples/convert-invalid-jsdoc-imports/output/index.d.ts new file mode 100644 index 0000000..335cb31 --- /dev/null +++ b/test/samples/convert-invalid-jsdoc-imports/output/index.d.ts @@ -0,0 +1,9 @@ +declare module 'preserve-comments-2' { + /** @ts-ignore this type is generated */ + import type { TypeA } from 'generated'; + export function a(): TypeA; + + export {}; +} + +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/test/samples/convert-invalid-jsdoc-imports/output/index.d.ts.map b/test/samples/convert-invalid-jsdoc-imports/output/index.d.ts.map new file mode 100644 index 0000000..1bcba9a --- /dev/null +++ b/test/samples/convert-invalid-jsdoc-imports/output/index.d.ts.map @@ -0,0 +1,14 @@ +{ + "version": 3, + "file": "index.d.ts", + "names": [ + "a" + ], + "sources": [ + "../input/index.js" + ], + "sourcesContent": [ + null + ], + "mappings": ";;;iBAMgBA,CAACA" +} \ No newline at end of file diff --git a/test/samples/preserve-ts-ignore/input/index.d.ts b/test/samples/preserve-ts-ignore/input/index.d.ts new file mode 100644 index 0000000..b2bbea5 --- /dev/null +++ b/test/samples/preserve-ts-ignore/input/index.d.ts @@ -0,0 +1,4 @@ +/** @ts-ignore this type is generated */ +import { TypeA } from 'generated'; + +export function a(): TypeA; diff --git a/test/samples/preserve-ts-ignore/output/index.d.ts b/test/samples/preserve-ts-ignore/output/index.d.ts new file mode 100644 index 0000000..335cb31 --- /dev/null +++ b/test/samples/preserve-ts-ignore/output/index.d.ts @@ -0,0 +1,9 @@ +declare module 'preserve-comments-2' { + /** @ts-ignore this type is generated */ + import type { TypeA } from 'generated'; + export function a(): TypeA; + + export {}; +} + +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/test/samples/preserve-ts-ignore/output/index.d.ts.map b/test/samples/preserve-ts-ignore/output/index.d.ts.map new file mode 100644 index 0000000..41afd3f --- /dev/null +++ b/test/samples/preserve-ts-ignore/output/index.d.ts.map @@ -0,0 +1,14 @@ +{ + "version": 3, + "file": "index.d.ts", + "names": [ + "a" + ], + "sources": [ + "../input/index.d.ts" + ], + "sourcesContent": [ + null + ], + "mappings": ";;;iBAGgBA,CAACA" +} \ No newline at end of file diff --git a/test/tsconfig.json b/test/tsconfig.json index b0d796d..1fa5b7e 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -13,5 +13,5 @@ "#lib": ["./samples/path-config/input/lib.d.ts"] } }, - "exclude": ["**/actual/"] + "exclude": ["**/actual/", "**/output/"] }