veritatisdoloremque
Version:
Monorepo for the Firebase JavaScript SDK
460 lines (430 loc) • 14.8 kB
text/typescript
/**
* @license
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as yargs from 'yargs';
import * as ts from 'typescript';
import * as fs from 'fs';
import { ESLint } from 'eslint';
/**
* Prunes a DTS file based on three main rules:
* - Top level types are only included if they are also exported.
* - Underscore-prefixed members of class and interface types are stripped.
* - Constructors are made private or protected if marked with
* `@hideconstructor`/`@hideconstructor protected`.
*
* This function is meant to operate on DTS files generated by API extractor
* and extracts out the API that is relevant for third-party SDK consumers.
*
* @param inputLocation The file path to the .d.ts produced by API explorer.
* @param outputLocation The output location for the pruned .d.ts file.
*/
export function pruneDts(inputLocation: string, outputLocation: string): void {
const compilerOptions = {};
const host = ts.createCompilerHost(compilerOptions);
const program = ts.createProgram([inputLocation], compilerOptions, host);
const printer: ts.Printer = ts.createPrinter();
const sourceFile = program.getSourceFile(inputLocation)!;
const result: ts.TransformationResult<ts.SourceFile> = ts.transform<ts.SourceFile>(
sourceFile,
[dropPrivateApiTransformer.bind(null, program, host)]
);
const transformedSourceFile: ts.SourceFile = result.transformed[0];
const content = printer.printFile(transformedSourceFile);
fs.writeFileSync(outputLocation, content);
}
export async function removeUnusedImports(
outputLocation: string
): Promise<void> {
const eslint = new ESLint({
fix: true,
overrideConfig: {
parserOptions: {
ecmaVersion: 2017,
sourceType: 'module',
tsconfigRootDir: __dirname,
project: ['./tsconfig.eslint.json']
},
env: {
es6: true
},
plugins: ['unused-imports', '@typescript-eslint'],
parser: '@typescript-eslint/parser',
rules: {
'unused-imports/no-unused-imports-ts': ['error']
}
}
});
const results = await eslint.lintFiles(outputLocation);
await ESLint.outputFixes(results);
}
/** Determines whether the provided identifier should be hidden. */
function hasPrivatePrefix(name: ts.Identifier): boolean {
// Identifiers that are prefixed with an underscore are not not included in
// the public API.
return name.escapedText.toString().startsWith('_');
}
/** Returns whether type identified by `name` is exported. */
function isExported(
typeChecker: ts.TypeChecker,
sourceFile: ts.SourceFile,
name: ts.Identifier
): boolean {
// Check is this is a public symbol (e.g. part of the DOM library)
const sourceFileNames = typeChecker
.getSymbolAtLocation(name)
?.declarations?.map(d => d.getSourceFile().fileName);
if (sourceFileNames?.find(s => s.indexOf('typescript/lib') != -1)) {
return true;
}
// Check is this is part of the exported symbols of the SDK module
const allExportedSymbols = typeChecker.getExportsOfModule(
typeChecker.getSymbolAtLocation(sourceFile)!
);
return !!allExportedSymbols.find(s => s.name === name.escapedText);
}
/**
* Replaces an existing constructor implementation if the constructor is marked
* with the JSDod tag `@hideconstructor`. The replaced constructor can either
* have `private` visibility` or `proctected`. To generate a protected
* constructor, specify `@hideconstructor proctected`.
*
* Returns either the modified constructor or the existing constructor if no
* modification was needed.
*/
function maybeHideConstructor(
node: ts.ConstructorDeclaration
): ts.ConstructorDeclaration {
const hideConstructorTag = ts
.getJSDocTags(node)
?.find(t => t.tagName.escapedText === 'hideconstructor');
if (hideConstructorTag) {
const modifier = ts.createModifier(
hideConstructorTag.comment === 'protected'
? ts.SyntaxKind.ProtectedKeyword
: ts.SyntaxKind.PrivateKeyword
);
return ts.createConstructor(
node.decorators,
[modifier],
/*parameters=*/ [],
/* body= */ undefined
);
} else {
return node;
}
}
/**
* Examines `extends` and `implements` clauses and removes or replaces them if
* they refer to a non-exported type. When an export is removed, all members
* from the removed class are merged into the provided class or interface
* declaration.
*
* @example
* Input:
* class Foo {
* foo: string;
* }
* export class Bar extends Foo {}
*
* Output:
* export class Bar {
* foo: string;
* }
*/
function prunePrivateImports<
T extends ts.InterfaceDeclaration | ts.ClassDeclaration
>(
program: ts.Program,
host: ts.CompilerHost,
sourceFile: ts.SourceFile,
node: T
): T {
const typeChecker = program.getTypeChecker();
// The list of heritage clauses after all private symbols are removed.
const prunedHeritageClauses: ts.HeritageClause[] = [];
// Additional members that are copied from the private symbols into the public
// symbols
const additionalMembers: ts.NamedDeclaration[] = [];
for (const heritageClause of node.heritageClauses || []) {
const exportedTypes: ts.ExpressionWithTypeArguments[] = [];
for (const type of heritageClause.types) {
if (
ts.isIdentifier(type.expression) &&
isExported(typeChecker, sourceFile, type.expression)
) {
exportedTypes.push(type);
} else {
// Hide the type we are inheriting from and merge its declarations
// into the current class.
// TODO: We really only need to do this when the type that is extended
// is a class. We should skip this for interfaces.
const privateType = typeChecker.getTypeAtLocation(type);
additionalMembers.push(
...convertPropertiesForEnclosingClass(
program,
host,
sourceFile,
privateType.getProperties(),
node
)
);
}
}
if (exportedTypes.length > 0) {
prunedHeritageClauses.push(
ts.updateHeritageClause(heritageClause, exportedTypes)
);
}
}
if (ts.isClassDeclaration(node)) {
return ts.updateClassDeclaration(
node,
node.decorators,
node.modifiers,
node.name,
node.typeParameters,
prunedHeritageClauses,
[
...(node.members as ts.NodeArray<ts.ClassElement>),
...(additionalMembers as ts.ClassElement[])
]
) as T;
} else if (ts.isInterfaceDeclaration(node)) {
return ts.updateInterfaceDeclaration(
node,
node.decorators,
node.modifiers,
node.name,
node.typeParameters,
prunedHeritageClauses,
[
...(node.members as ts.NodeArray<ts.TypeElement>),
...(additionalMembers as ts.TypeElement[])
]
) as T;
} else {
throw new Error('Only classes or interfaces are supported');
}
}
/**
* Iterates the provided symbols and returns named declarations for these
* symbols if they are missing from `currentClass`. This allows us to merge
* class hierarchies for classes whose inherited types are not part of the
* public API.
*
* This method relies on a private API in TypeScript's `codefix` package.
*/
function convertPropertiesForEnclosingClass(
program: ts.Program,
host: ts.CompilerHost,
sourceFile: ts.SourceFile,
parentClassSymbols: ts.Symbol[],
currentClass: ts.ClassDeclaration | ts.InterfaceDeclaration
): ts.NamedDeclaration[] {
const newMembers: ts.NamedDeclaration[] = [];
// The `codefix` package is not public but it does exactly what we want. It's
// the same package that is used by VSCode to fill in missing members, which
// is what we are using it for in this script. `codefix` handles missing
// properties, methods and correctly deduces generics.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ts as any).codefix.createMissingMemberNodes(
currentClass,
parentClassSymbols,
sourceFile,
{ program, host },
/* userPreferences= */ {},
/* importAdder= */ undefined,
(c: ts.ClassElement) => newMembers.push(c)
);
return newMembers;
}
/**
* Replaces input types of public APIs that consume non-exported types, which
* allows us to exclude private types from the pruned definitions. Returns the
* the name of the exported API or undefined if no type is found.
*
* @example
* Input:
* class PrivateFoo {}
* export class PublicFoo extends PrivateFoo {}
* export function doFoo(foo: PrivateFoo);
*
* Output:
* export class PublicFoo {}
* export function doFoo(foo: PublicFoo);
*/
function extractExportedSymbol(
typeChecker: ts.TypeChecker,
sourceFile: ts.SourceFile,
typeName: ts.Node
): ts.Symbol | undefined {
if (!ts.isIdentifier(typeName)) {
return undefined;
}
if (isExported(typeChecker, sourceFile, typeName)) {
// Don't replace the type reference if the type is already part of the
// public API.
return undefined;
}
const localSymbolName = typeName.escapedText;
const allExportedSymbols = typeChecker.getExportsOfModule(
typeChecker.getSymbolAtLocation(sourceFile)!
);
// Examine all exported types and check if they extend or implement the
// provided local type. If so, we can use the exported type in lieu of the
// private type.
// Short circuit if the local types is already part of the public types.
for (const symbol of allExportedSymbols) {
if (symbol.name === localSymbolName) {
return symbol;
}
}
// See if there is an exported symbol that extends this private symbol.
// In this case, we can safely use the public symbol instead.
for (const symbol of allExportedSymbols) {
for (const declaration of symbol.declarations) {
if (
ts.isClassDeclaration(declaration) ||
ts.isInterfaceDeclaration(declaration)
) {
for (const heritageClause of declaration.heritageClauses || []) {
for (const type of heritageClause.types || []) {
if (ts.isIdentifier(type.expression)) {
const subclassName = type.expression.escapedText;
if (subclassName === localSymbolName) {
// TODO: We may need to change this to return a Union type if
// more than one public type corresponds to the private type.
return symbol;
}
}
}
}
}
}
}
// If no symbol was found that extends the private symbol, check the reverse.
// We might find an exported symbol in the inheritance chain of the local
// symbol. Note that this is not always safe as we might replace the local
// symbol with a less restrictive type.
const localSymbol = typeChecker.getSymbolAtLocation(typeName);
if (localSymbol) {
for (const declaration of localSymbol!.declarations) {
if (
ts.isClassDeclaration(declaration) ||
ts.isInterfaceDeclaration(declaration)
) {
for (const heritageClause of declaration.heritageClauses || []) {
for (const type of heritageClause.types || []) {
if (ts.isIdentifier(type.expression)) {
if (isExported(typeChecker, sourceFile, type.expression)) {
return typeChecker.getSymbolAtLocation(type.expression);
}
}
}
}
}
}
}
return undefined;
}
function dropPrivateApiTransformer(
program: ts.Program,
host: ts.CompilerHost,
context: ts.TransformationContext
): ts.Transformer<ts.SourceFile> {
const typeChecker = program.getTypeChecker();
return (sourceFile: ts.SourceFile) => {
function visit(node: ts.Node): ts.Node {
if (
ts.isInterfaceDeclaration(node) ||
ts.isClassDeclaration(node) ||
ts.isFunctionDeclaration(node) ||
ts.isVariableStatement(node) ||
ts.isTypeAliasDeclaration(node) ||
ts.isModuleDeclaration(node) ||
ts.isEnumDeclaration(node)
) {
// Remove any types that are not exported.
if (
!node.modifiers?.find(m => m.kind === ts.SyntaxKind.ExportKeyword)
) {
return ts.createToken(ts.SyntaxKind.WhitespaceTrivia);
}
}
if (ts.isConstructorDeclaration(node)) {
// Replace internal constructors with private constructors.
return maybeHideConstructor(node);
} else if (
ts.isClassDeclaration(node) ||
ts.isInterfaceDeclaration(node)
) {
// Remove any imports that reference internal APIs, while retaining
// their public members.
return prunePrivateImports(program, host, sourceFile, node);
} else if (
ts.isPropertyDeclaration(node) ||
ts.isMethodDeclaration(node) ||
ts.isGetAccessor(node)
) {
// Remove any class and interface members that are prefixed with
// underscores.
if (hasPrivatePrefix(node.name as ts.Identifier)) {
return ts.createToken(ts.SyntaxKind.WhitespaceTrivia);
}
} else if (ts.isTypeReferenceNode(node)) {
// For public types that refer internal types, find a public type that
// we can refer to instead.
const publicName = extractExportedSymbol(
typeChecker,
sourceFile,
node.typeName
);
return publicName
? ts.updateTypeReferenceNode(
node,
ts.createIdentifier(publicName.name),
node.typeArguments
)
: node;
}
return node;
}
function visitNodeAndChildren<T extends ts.Node>(node: T): T {
return ts.visitEachChild(
visit(node),
childNode => visitNodeAndChildren(childNode),
context
) as T;
}
return visitNodeAndChildren(sourceFile);
};
}
const argv = yargs.options({
input: {
type: 'string',
desc: 'The location of the index.ts file'
},
output: {
type: 'string',
desc: 'The location for the index.d.ts file'
}
}).argv;
if (argv.input && argv.output) {
console.log('Removing private exports...');
pruneDts(argv.input, argv.output);
console.log('Removing unused imports...');
removeUnusedImports(argv.output).then(() => console.log('Done.'));
}