UNPKG

dts-buddy

Version:

A tool for creating .d.ts bundles

651 lines (564 loc) 17.9 kB
/** @import { Binding, Declaration, Module, Namespace } from './types' */ import fs from 'node:fs'; import path from 'node:path'; import { globSync } from 'tinyglobby'; import ts from 'typescript'; import * as tsu from 'ts-api-utils'; import { getLocator } from 'locate-character'; import { decode } from '@jridgewell/sourcemap-codec'; const preserved_jsdoc_tags = new Set(['default', 'deprecated', 'example']); /** @param {ts.Node} node */ export function get_jsdoc(node) { const { jsDoc } = /** @type {{ jsDoc?: ts.JSDoc[] }} */ (/** @type {*} */ (node)); return jsDoc; } /** @param {ts.Node} node */ export function is_internal(node) { const jsdoc = get_jsdoc(node); if (jsdoc) { for (const jsDoc of jsdoc) { if (jsDoc.tags?.some((tag) => tag.tagName.escapedText === 'internal')) { return true; } } } return false; } /** @param {ts.Node} node */ export function get_jsdoc_imports(node) { /** @type {import('typescript').TypeNode[]} */ const imports = []; const jsdoc = get_jsdoc(node); for (const comment of jsdoc ?? []) { for (const tag of comment.tags ?? []) { collect_jsdoc_imports(tag, imports); } } return imports; } /** * * @param {ts.JSDocTag} node * @param {ts.TypeNode[]} imports */ function collect_jsdoc_imports(node, imports) { const type_expression = /** @type {ts.JSDocTag & { typeExpression?: ts.Node}} */ (node) .typeExpression; if (type_expression) { /** * @type {ts.JSDocTag[]} */ const sub_tags = []; if (ts.isJSDocTypeLiteral(type_expression)) { sub_tags.push(...(type_expression.jsDocPropertyTags ?? [])); } else if (ts.isJSDocSignature(type_expression)) { sub_tags.push(...type_expression.parameters, ...(type_expression.typeParameters ?? [])); if (type_expression.type) { sub_tags.push(type_expression.type); } } else if (ts.isJSDocTypeExpression(type_expression)) { walk(type_expression.type, (node) => { if (ts.isImportTypeNode(node)) { imports.push(node.argument); } }); } for (const sub_tag of sub_tags) { collect_jsdoc_imports(sub_tag, imports); } } } /** * @param {ts.Node} node * @param {import('magic-string').default} code */ export function clean_jsdoc(node, code) { const jsdoc = get_jsdoc(node); if (jsdoc) { for (const jsDoc of jsdoc) { let should_keep = !!jsDoc.comment; jsDoc.tags?.forEach((tag) => { const type = /** @type {string} */ (tag.tagName.escapedText); // @ts-ignore const name = /** @type {ts.Identifier | undefined} */ (tag.name); if (name) { // @ts-ignore if (tag.isBracketed) { // in JSDoc, we might have an optional [foo] parameter. in a .d.ts context, // the brackets cause the parameter to be interpreted as a comment, // so we have to remove them let a = name.pos - 1; let b = name.end; while (code.original[a] === ' ') a -= 1; while (code.original[b] === ' ') b += 1; code.remove(a, name.pos); code.remove(name.end, b + 1); } } if (tag.comment) { should_keep = true; if (type === 'param' || type === 'returns') { const typeExpression = /** @type {ts.JSDocTypeExpression | undefined} */ ( // @ts-ignore tag.typeExpression ); if (typeExpression) { // turn `@param {string} foo description` into `@param foo description` let a = typeExpression.pos; let b = typeExpression.end; while (code.original[b] === ' ') b += 1; code.remove(a, b); } } } else if (preserved_jsdoc_tags.has(type)) { should_keep = true; } else { code.remove(tag.pos, tag.end); } }); if (!should_keep) { code.remove(jsDoc.pos, jsDoc.end); } } } } /** * @param {string} cwd * @param {string[]} include * @param {string[]} exclude * @returns {string[]} */ export function get_input_files(cwd, include, exclude) { /** @type {Set<string>} */ const included = new Set(); for (const pattern of include) { for (const file of globSync(pattern, { cwd, ignore: exclude })) { const resolved = path.resolve(cwd, file); if (fs.statSync(resolved).isDirectory()) { for (const file of globSync('**/*.{js,jsx,ts,tsx}', { cwd: resolved, ignore: exclude })) { included.add(path.resolve(resolved, file)); } } else { included.add(resolved); } } } return Array.from(included).map((file) => path.resolve(file)); } /** * @param {string} file * @param {string} contents */ export function write(file, contents) { try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch {} fs.writeFileSync(file, contents); } /** * @param {string} file * @param {Record<string, string>} created * @param {(file: string, specifier: string) => string | null} resolve * @param {{ stripInternal?: boolean }} options */ export function get_dts(file, created, resolve, options) { const dts = created[file] ?? fs.readFileSync(file, 'utf8'); const ast = ts.createSourceFile(file, dts, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const locator = getLocator(dts, { offsetLine: 1 }); /** @type {Module} */ const module = { file, dts, ast, locator, source: null, dependencies: [], globals: [], references: new Set(), declarations: new Map(), imports: new Map(), exports: new Map(), export_from: new Map(), import_all: new Map(), export_all: [], ambient_imports: [] }; if (file in created) { const map = JSON.parse(created[file + '.map']); const source_file = path.resolve(path.dirname(file), map.sources[0]); const code = fs.readFileSync(source_file, 'utf8'); module.source = { code, map, mappings: decode(map.mappings) }; } /** @type {Module | Namespace} */ let current = module; /** @param {ts.Node} node */ function scan(node) { // follow imports if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { const { text } = node.moduleSpecifier; const resolved = resolve(file, text); const external = !resolved; const id = resolved ?? text; // if a local module, and _not_ an ambient import, add it to dependencies if (!external && !(ts.isImportDeclaration(node) && !node.importClause)) { module.dependencies.push(id); } if (ts.isImportDeclaration(node)) { if (node.importClause) { // `import foo` if (node.importClause.name) { const name = node.importClause.name.getText(module.ast); module.imports.set(name, { id, external, name: 'default' }); } else if (node.importClause.namedBindings) { // `import * as foo` if (ts.isNamespaceImport(node.importClause.namedBindings)) { const name = node.importClause.namedBindings.name.getText(module.ast); module.import_all.set(name, { id, external, name }); } // `import { foo }`, `import { foo as bar }` else { node.importClause.namedBindings.elements.forEach((specifier) => { const local = specifier.name.getText(module.ast); module.imports.set(local, { id, external, name: specifier.propertyName?.getText(module.ast) ?? local }); }); } } } else { // assume this is an ambient module module.ambient_imports.push({ id, external }); } } if (ts.isExportDeclaration(node)) { if (node.exportClause && ts.isNamedExports(node.exportClause)) { // `export { foo as bar } from '...'` if (ts.isNamedExports(node.exportClause)) { node.exportClause.elements.forEach((specifier) => { const name = specifier.name.getText(module.ast); const local = specifier.propertyName ? specifier.propertyName.getText(module.ast) : name; module.export_from.set(name, { id, external, name: local }); }); } } else { // `export * as foo from '...'` if (node.exportClause) { // in this case, we need to generate an `export namespace` declaration const name = node.exportClause?.name?.getText(module.ast) ?? null; throw new Error(`TODO export * as ${name}`); } // `export * from '...'` module.export_all.push({ id, external }); } } } else if (ts.isExportDeclaration(node)) { if (node.exportClause && ts.isNamedExports(node.exportClause)) { // `export { foo as bar }` if (ts.isNamedExports(node.exportClause)) { node.exportClause.elements.forEach((specifier) => { const name = specifier.name.getText(module.ast); const local = specifier.propertyName ? specifier.propertyName.getText(module.ast) : name; module.exports.set(name, local); }); } } } return; } if (is_declaration(node)) { if (is_internal(node) && options.stripInternal) return; const identifier = ts.isVariableStatement(node) ? ts.getNameOfDeclaration(node.declarationList.declarations[0]) : ts.getNameOfDeclaration(node); if (!identifier) { throw new Error('TODO'); // unnamed default export? } const name = identifier.getText(module.ast); // in the case of overloads, declaration may already exist const existing = current.declarations.get(name); if (!existing) { current.declarations.set(name, { module: file, name, alias: '', export: false, default: false, included: false, external: false, dependencies: [], preferred_alias: '' }); } const declaration = /** @type {Declaration} */ (current.declarations.get(name)); const export_modifier = node.modifiers?.find((node) => tsu.isExportKeyword(node)); if (export_modifier) { const default_modifier = node.modifiers?.find((node) => tsu.isDefaultKeyword(node)); current.exports.set(default_modifier ? 'default' : name, name); } const params = new Set(); if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) { if (node.typeParameters) { for (const param of node.typeParameters) { params.add(param.name.getText(module.ast)); } } } if (tsu.isNamespaceDeclaration(node)) { const previous = current; current = { declarations: new Map(), references: new Set(), exports: new Map() }; node.body.forEachChild(scan); for (const name of current.references) { if (!current.declarations.has(name)) { previous.references.add(name); } } for (const inner of current.declarations.values()) { for (const inner_dep of inner.dependencies) { if ( !declaration.dependencies.some( (dep) => dep.name === inner_dep.name && dep.module === inner_dep.module ) ) { declaration.dependencies.push(inner_dep); } } } current = previous; } else { walk(node, (node) => { if (ts.isPropertySignature(node) && is_internal(node) && options.stripInternal) { return false; } // `import('./foo').Foo` -> `Foo` if ( ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal) ) { // follow import const resolved = resolve(file, node.argument.literal.text); if (resolved) { module.dependencies.push(resolved); if (node.qualifier) { // In the case of `import('./foo').Foo.Bar`, this contains `Foo.Bar`, // but we only want `Foo` (because we don't traverse into namespaces) let id = node.qualifier; while (ts.isQualifiedName(id)) { id = id.left; } declaration.dependencies.push({ module: resolved ?? node.argument.literal.text, name: id.getText(module.ast) }); } } } if (is_reference(node)) { const name = node.getText(module.ast); if (params.has(name)) return; current.references.add(name); if (name !== declaration.name) { // If this references an import * as X statement, we add a dependency to Y of the X.Y access if (module.import_all.has(name) && ts.isQualifiedName(node.parent)) { declaration.dependencies.push({ module: /** @type {Binding} */ (module.import_all.get(name)).id, name: node.parent.right.getText(module.ast) }); } else { declaration.dependencies.push({ module: file, name }); } } } }); } return; } if (ts.isExportAssignment(node)) { const name = node.expression.getText(module.ast); current.exports.set('default', name); return; } if (ts.isModuleDeclaration(node)) { return; } if (tsu.isEndOfFileToken(node)) return; if (ts.isEnumDeclaration(node)) return; // throw new Error(`Unimplemented node type ${ts.SyntaxKind[node.kind]}`); } ast.statements.forEach(scan); for (const name of module.references) { if (!module.declarations.has(name) && !module.imports.has(name)) { module.globals.push(name); } } return module; } /** * @param {string} from * @param {string} to */ export function resolve_dts(from, to) { const file = path.resolve(from, to); if (file.endsWith('.d.ts')) return file; if (file.endsWith('.ts')) return file.replace(/\.ts$/, '.d.ts'); if (file.endsWith('.js')) return file.replace(/\.js$/, '.d.ts'); if (file.endsWith('.jsx')) return file.replace(/\.jsx$/, '.d.ts'); if (file.endsWith('.tsx')) return file.replace(/\.tsx$/, '.d.ts'); if (fs.existsSync(file) && fs.statSync(file).isDirectory()) return file + '/index.d.ts'; return file + '.d.ts'; } /** * @param {ts.Node} node * @param {(node: ts.Node) => void | false} callback */ export function walk(node, callback) { const go_on = callback(node); if (go_on !== false) { ts.forEachChild(node, (child) => walk(child, callback)); } } /** * @param {ts.Node} node * @returns {node is * ts.InterfaceDeclaration | * ts.TypeAliasDeclaration | * ts.ClassDeclaration | * ts.FunctionDeclaration | * ts.VariableStatement | * ts.EnumDeclaration | * ts.ModuleDeclaration * } */ export function is_declaration(node) { return ( ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node) || ts.isVariableStatement(node) || ts.isEnumDeclaration(node) || tsu.isNamespaceDeclaration(node) ); } /** * @param {ts.Node} node * @param {boolean} [include_declarations] * @returns {node is ts.Identifier} */ export function is_reference(node, include_declarations = false) { if (!ts.isIdentifier(node)) return false; if (node.parent) { if (is_declaration(node.parent)) { if (ts.isVariableStatement(node.parent)) { return false; } return include_declarations && node.parent.name === node; } if (ts.isPropertyAccessExpression(node.parent)) return node === node.parent.expression; if (ts.isPropertyDeclaration(node.parent)) return node === node.parent.initializer; if (ts.isPropertyAssignment(node.parent)) return node === node.parent.initializer; if (ts.isMethodSignature(node.parent)) return node !== node.parent.name; if (ts.isImportTypeNode(node.parent)) return false; if (ts.isPropertySignature(node.parent)) return false; if (ts.isGetAccessor(node.parent)) return false; if (ts.isSetAccessor(node.parent)) return false; if (ts.isParameter(node.parent)) return false; if (ts.isMethodDeclaration(node.parent)) return false; if (ts.isLabeledStatement(node.parent)) return false; if (ts.isBreakOrContinueStatement(node.parent)) return false; if (ts.isEnumMember(node.parent)) return false; if (ts.isModuleDeclaration(node.parent)) return false; // Only X in X.Y.Z is a reference we care about if (ts.isQualifiedName(node.parent)) { return node.parent.left === node && ts.isIdentifier(node.parent.right); } // `const = { x: 1 }` inexplicably becomes `namespace a { let x: number; }` if (ts.isVariableDeclaration(node.parent)) { if (node === node.parent.initializer) return true; const ancestor = node.parent.parent?.parent?.parent?.parent; if (ancestor && tsu.isNamespaceDeclaration(node.parent.parent.parent.parent.parent)) { return false; } } } return true; } /** * parse tsconfig.json with typescript api * @param {string} tsconfig_file * @returns {{ * include: string[]|undefined, * exclude: string[]|undefined, * compilerOptions: ts.CompilerOptions * }} * @throws {Error} if ts api returns error diagnostics */ export function parse_tsconfig(tsconfig_file) { const { config, error: read_diagnostic } = ts.readConfigFile(tsconfig_file, ts.sys.readFile); if (read_diagnostic != null) { report_ts_errors(tsconfig_file, 'readConfigFile', [read_diagnostic]); } const { raw, options, errors: parse_diagnostics } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(tsconfig_file)); report_ts_errors(tsconfig_file, 'parseJsonConfigFileContent', parse_diagnostics); // only returns what's needed later on return { include: raw.include, exclude: raw.exclude, compilerOptions: options }; } /** * log and throw error diagnostics * @param {string} tsconfig_file * @param {string} phase * @param {ts.Diagnostic[]} diagnostics */ function report_ts_errors(tsconfig_file, phase, diagnostics) { const errors = diagnostics.filter((d) => d.category === ts.DiagnosticCategory.Error); if (errors.length > 0) { const msg = `parsing ${tsconfig_file} failed during ${phase}`; console.error( `${msg}\n`, ts.formatDiagnostics(diagnostics, { getCurrentDirectory: () => ts.sys.getCurrentDirectory(), getCanonicalFileName: (f) => f, getNewLine: () => '\n' }) ); throw new Error(msg); } }