From 77fd6c12a93e0bf036f72ce7a5733ac1906c5a4e Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 30 Jul 2025 23:53:32 -0700 Subject: [PATCH 1/2] Support regular expressions for library options --- .vscode/launch.json | 14 ++++ src/bundle-generator.ts | 19 ++--- src/collisions-resolver.ts | 2 +- src/config-file/check-schema-match.ts | 79 +++++++++++++------ src/config-file/load-config-file.ts | 8 +- src/module-info.ts | 18 +++-- .../inline-from-deps-regexp/config.ts | 13 +++ .../inline-from-deps-regexp/index.spec.js | 1 + .../inline-from-deps-regexp/input.ts | 10 +++ .../inline-from-deps-regexp/output.d.ts | 21 +++++ tests/unittests/check-schema-match.spec.ts | 78 +++++++++++++++++- 11 files changed, 216 insertions(+), 47 deletions(-) create mode 100644 tests/e2e/test-cases/inline-from-deps-regexp/config.ts create mode 100644 tests/e2e/test-cases/inline-from-deps-regexp/index.spec.js create mode 100644 tests/e2e/test-cases/inline-from-deps-regexp/input.ts create mode 100644 tests/e2e/test-cases/inline-from-deps-regexp/output.d.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index d1275e29..c2cfbe57 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,6 +14,20 @@ ], "internalConsoleOptions": "openOnSessionStart" }, + { + "type": "node", + "request": "launch", + "name": "Debug current test", + "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", + "cwd": "${workspaceFolder}", + "args": [ + "-w", + "--extension", + "ts,js", + "${file}" + ], + "internalConsoleOptions": "openOnSessionStart" + }, { "type": "node", "request": "attach", diff --git a/src/bundle-generator.ts b/src/bundle-generator.ts index e33dab8d..7e5994e4 100644 --- a/src/bundle-generator.ts +++ b/src/bundle-generator.ts @@ -98,6 +98,7 @@ export interface OutputOptions { /** * By default all interfaces, types and const enums are marked as exported even if they aren't exported directly. * This option allows you to disable this behavior so a node will be exported if it is exported from root source file only. + * @default true */ exportReferencedTypes?: boolean; } @@ -107,20 +108,20 @@ export interface LibrariesOptions { * Array of package names from node_modules to inline typings from. * Used types will be inlined into the output file. */ - inlinedLibraries?: string[]; + inlinedLibraries?: (string | RegExp)[]; /** * Array of package names from node_modules to import typings from. * Used types will be imported using `import { First, Second } from 'library-name';`. - * By default all libraries will be imported (except inlined libraries and libraries from @types). + * By default all libraries will be imported (except inlined libraries and `@types`). */ - importedLibraries?: string[]; + importedLibraries?: (string | RegExp)[]; /** - * Array of package names from @types to import typings from via the triple-slash reference directive. + * Array of package names from `@types` to import typings from via the triple-slash reference directive. * By default all packages are allowed and will be used according to their usages. */ - allowedTypesLibraries?: string[]; + allowedTypesLibraries?: (string | RegExp)[]; } export interface EntryPointConfig { @@ -660,7 +661,7 @@ export function generateDtsBundle(entries: readonly EntryPointConfig[], options: importItem.requireImports.add(collisionsResolver.addTopLevelIdentifier(preferredLocalName)); } - function addNamedImport(importItem: ModuleImportsSet, preferredLocalName: ts.Identifier, importedIdentifier: ts.Identifier): void { + function addNamedImport(importItem: ModuleImportsSet, preferredLocalName: ts.ModuleExportName, importedIdentifier: ts.ModuleExportName): void { const newLocalName = collisionsResolver.addTopLevelIdentifier(preferredLocalName); const importedName = importedIdentifier.text; importItem.namedImports.set(newLocalName, importedName); @@ -671,7 +672,7 @@ export function generateDtsBundle(entries: readonly EntryPointConfig[], options: importItem.reExports.set(reExportedName, moduleExportedName); } - function addNsImport(importItem: ModuleImportsSet, preferredLocalName: ts.Identifier): void { + function addNsImport(importItem: ModuleImportsSet, preferredLocalName: ts.ModuleExportName): void { if (importItem.nsImport === null) { importItem.nsImport = collisionsResolver.addTopLevelIdentifier(preferredLocalName); } @@ -1092,7 +1093,7 @@ export function generateDtsBundle(entries: readonly EntryPointConfig[], options: } // eslint-disable-next-line complexity - function getIdentifierOfNamespaceImportFromInlinedModule(nsSymbol: ts.Symbol): ts.Identifier | null { + function getIdentifierOfNamespaceImportFromInlinedModule(nsSymbol: ts.Symbol): ts.ModuleExportName | null { // handling namespaced re-exports/imports // e.g. `export * as NS from './local-module';` or `import * as NS from './local-module'; export { NS }` for (const decl of getDeclarationsForSymbol(nsSymbol)) { @@ -1238,7 +1239,7 @@ export function generateDtsBundle(entries: readonly EntryPointConfig[], options: throw new Error(`Cannot find symbol or exports for source file ${sourceFile.fileName}`); } - let namespaceIdentifier: ts.Identifier | null = null; + let namespaceIdentifier: ts.ModuleExportName | null = null; forEachImportOfStatement(sourceFile, (imp: ImportOfStatement) => { // here we want to handle creation of artificial namespace for a inlined module diff --git a/src/collisions-resolver.ts b/src/collisions-resolver.ts index cd8c1c4b..dd89b873 100644 --- a/src/collisions-resolver.ts +++ b/src/collisions-resolver.ts @@ -42,7 +42,7 @@ export class CollisionsResolver { /** * Adds (or "registers") a top-level {@link identifier} (which takes a top-level scope name to use). */ - public addTopLevelIdentifier(identifier: ts.Identifier | ts.DefaultKeyword): string { + public addTopLevelIdentifier(identifier: ts.Identifier | ts.DefaultKeyword | ts.ModuleExportName): string { const symbol = getDeclarationNameSymbol(identifier, this.typeChecker); if (symbol === null) { throw new Error(`Something went wrong - cannot find a symbol for top-level identifier ${identifier.getText()} (from ${identifier.parent.parent.getText()})`); diff --git a/src/config-file/check-schema-match.ts b/src/config-file/check-schema-match.ts index 10ef746c..a0f3f173 100644 --- a/src/config-file/check-schema-match.ts +++ b/src/config-file/check-schema-match.ts @@ -3,20 +3,37 @@ export interface PrimitiveValues { requiredBoolean: true; string: ''; requiredString: 'REQUIRED'; + stringOrRegExp: 'StringOrRegExp'; } -export type SchemeDescriptor = { - [P in keyof T]-?: T[P] extends unknown[] ? [SchemeDescriptor] : SchemeDescriptor; +export type SchemeDescriptorObject = { + [P in keyof T]-?: SchemeDescriptor>; }; +export type SchemeDescriptor = + // Check for string-only first. [] prevents distributive conditional types from being applied to `string | RegExp`. + [T] extends [string] + ? PrimitiveValues['string'] | PrimitiveValues['requiredString'] + : [T] extends [string | RegExp] + ? PrimitiveValues['stringOrRegExp'] + : T extends boolean + ? PrimitiveValues['boolean'] | PrimitiveValues['requiredBoolean'] + : T extends unknown[] + ? [SchemeDescriptor] + : T extends object + ? SchemeDescriptorObject + // Value type is not currently supported + : never; + export const schemaPrimitiveValues: Readonly = { boolean: false, requiredBoolean: true, string: '', requiredString: 'REQUIRED', + stringOrRegExp: 'StringOrRegExp', }; -const schemaRequiredValues = new Set([ +const schemaRequiredValues = new Set([ schemaPrimitiveValues.requiredBoolean, schemaPrimitiveValues.requiredString, ]); @@ -31,35 +48,48 @@ export function checkSchemaMatch(value: unknown, schema: SchemeDescriptor, } // eslint-disable-next-line complexity -function checkSchemaMatchRecursively(value: unknown, schema: SchemeDescriptor | [SchemeDescriptor], prefix: string, errors: string[]): value is T { - if (typeof schema === 'boolean' || typeof schema === 'string') { - const schemeType = typeof schema; - if (value === undefined && schemaRequiredValues.has(schema)) { - errors.push(`Value for "${prefix}" is required and must have type "${schemeType}"`); +function checkSchemaMatchRecursively(value: unknown, schema: SchemeDescriptor, prefix: string, errors: string[]): value is T { + if (value === undefined && schemaRequiredValues.has(schema)) { + errors.push(`Value for "${prefix}" is required and must have type "${typeof schema}"`); + return false; + } + + if (value === undefined || value === null) { + return true; + } + + if (schema === schemaPrimitiveValues.stringOrRegExp) { + if (!(value instanceof RegExp) && typeof value !== 'string') { + errors.push(`Value for "${prefix}" must be a string or RegExp`); return false; } + return true; + } + if (typeof schema === 'boolean' || typeof schema === 'string') { + const schemeType = typeof schema; const valueType = typeof value; - if (value !== undefined && typeof schema !== valueType) { - errors.push(`Type of values for "${prefix}" is not the same, expected=${schemeType}, actual=${valueType}`); + if (schemeType !== valueType) { + errors.push(`Incorrect value type for "${prefix}": expected=${schemeType}, actual=${valueType}`); return false; } return true; } - if (value === undefined) { - return true; - } - if (Array.isArray(schema)) { if (!Array.isArray(value)) { + errors.push(`Value for "${prefix}" must be an array`); return false; } let result = true; for (let i = 0; i < value.length; ++i) { - if (!checkSchemaMatchRecursively(value[i], schema[0], `${prefix}[${i}]`, errors)) { + if (value[i] === undefined || value[i] === null) { + // undefined is not valid within arrays + errors.push(`Value for "${prefix}[${i}]" is ${value[i]}`); + result = false; + } else if (!checkSchemaMatchRecursively(value[i], schema[0], `${prefix}[${i}]`, errors)) { result = false; } } @@ -67,21 +97,26 @@ function checkSchemaMatchRecursively(value: unknown, schema: SchemeDescriptor return result; } - type SchemeKey = keyof SchemeDescriptor; - type SchemeSubValue = SchemeDescriptor; + if (typeof value !== 'object') { + errors.push(`Value for "${prefix}" must be an object`); + return false; + } + + // At this point the schema and T are objects, but the compiler can't infer it + const schemaObject = schema as SchemeDescriptorObject>; let result = true; - for (const valueKey of Object.keys(value as object)) { - if (schema[valueKey as keyof T] === undefined) { - errors.push(`Exceeded property "${valueKey}" found in ${prefix.length === 0 ? 'the root' : prefix}`); + for (const valueKey of Object.keys(value)) { + if (schemaObject[valueKey] === undefined) { + errors.push(`Excess property "${valueKey}" found in ${prefix.length === 0 ? 'the root' : prefix}`); result = false; } } - for (const schemaKey of Object.keys(schema)) { + for (const schemaKey of Object.keys(schemaObject)) { const isSubValueSchemeMatched = checkSchemaMatchRecursively( (value as Record)[schemaKey], - schema[schemaKey as SchemeKey] as SchemeSubValue, + schemaObject[schemaKey], prefix.length === 0 ? schemaKey : `${prefix}.${schemaKey}`, errors ); diff --git a/src/config-file/load-config-file.ts b/src/config-file/load-config-file.ts index c907fdea..08fb3e3e 100644 --- a/src/config-file/load-config-file.ts +++ b/src/config-file/load-config-file.ts @@ -31,7 +31,7 @@ export function loadConfigFile(configPath: string): BundlerConfig { const possibleConfig = require(getAbsolutePath(configPath)); const errors: string[] = []; - if (!checkSchemaMatch(possibleConfig, configScheme, errors)) { + if (!checkSchemaMatch(possibleConfig, configScheme, errors)) { errorLog(errors.join('\n')); throw new Error('Cannot parse config file'); } @@ -67,9 +67,9 @@ const configScheme: SchemeDescriptor = { failOnClass: schemaPrimitiveValues.boolean, noCheck: schemaPrimitiveValues.boolean, libraries: { - allowedTypesLibraries: [schemaPrimitiveValues.string], - importedLibraries: [schemaPrimitiveValues.string], - inlinedLibraries: [schemaPrimitiveValues.string], + allowedTypesLibraries: [schemaPrimitiveValues.stringOrRegExp], + importedLibraries: [schemaPrimitiveValues.stringOrRegExp], + inlinedLibraries: [schemaPrimitiveValues.stringOrRegExp], }, output: { inlineDeclareGlobals: schemaPrimitiveValues.boolean, diff --git a/src/module-info.ts b/src/module-info.ts index d0fb1644..c6a8a174 100644 --- a/src/module-info.ts +++ b/src/module-info.ts @@ -45,9 +45,9 @@ export interface UsedForModulesModuleInfo extends UsedModuleInfoCommon { export type ModuleInfo = InlinedModuleInfo | ImportedModuleInfo | ReferencedModuleInfo | UsedForModulesModuleInfo; export interface ModuleCriteria { - inlinedLibraries: string[]; - importedLibraries: string[] | undefined; - allowedTypesLibraries: string[] | undefined; + inlinedLibraries: (string | RegExp)[]; + importedLibraries: (string | RegExp)[] | undefined; + allowedTypesLibraries: (string | RegExp)[] | undefined; typeRoots?: string[]; } @@ -120,15 +120,15 @@ function getModuleInfoImpl(currentFilePath: string, originalFileName: string, cr return { type: ModuleType.ShouldBeUsedForModulesOnly, fileName: originalFileName, isExternal: true }; } -function shouldLibraryBeInlined(npmLibraryName: string, typesLibraryName: string | null, inlinedLibraries: string[]): boolean { +function shouldLibraryBeInlined(npmLibraryName: string, typesLibraryName: string | null, inlinedLibraries: (string | RegExp)[]): boolean { return isLibraryAllowed(npmLibraryName, inlinedLibraries) || typesLibraryName !== null && isLibraryAllowed(typesLibraryName, inlinedLibraries); } function shouldLibraryBeImported( npmLibraryName: string, typesLibraryName: string | null, - importedLibraries: string[] | undefined, - allowedTypesLibraries: string[] | undefined + importedLibraries: (string | RegExp)[] | undefined, + allowedTypesLibraries: (string | RegExp)[] | undefined ): boolean { if (typesLibraryName === null) { return isLibraryAllowed(npmLibraryName, importedLibraries); @@ -144,8 +144,10 @@ function shouldLibraryBeImported( return false; } -function isLibraryAllowed(libraryName: string, allowedArray?: string[]): boolean { - return allowedArray === undefined || allowedArray.indexOf(libraryName) !== -1; +function isLibraryAllowed(libraryName: string, allowed: (string | RegExp)[] | undefined): boolean { + return Array.isArray(allowed) + ? allowed.some(item => typeof item === 'string' ? item === libraryName : item.test(libraryName)) + : true; } function remapToTypesFromNodeModules(pathRelativeToTypesRoot: string): string { diff --git a/tests/e2e/test-cases/inline-from-deps-regexp/config.ts b/tests/e2e/test-cases/inline-from-deps-regexp/config.ts new file mode 100644 index 00000000..c71169fd --- /dev/null +++ b/tests/e2e/test-cases/inline-from-deps-regexp/config.ts @@ -0,0 +1,13 @@ +import { TestCaseConfig } from '../test-case-config'; + +const config: TestCaseConfig = { + libraries: { + inlinedLibraries: [ + /^fake-package$/, + 'fake-types-lib-1', + 'fake-types-lib-', // not matched + ] + }, +}; + +export = config; diff --git a/tests/e2e/test-cases/inline-from-deps-regexp/index.spec.js b/tests/e2e/test-cases/inline-from-deps-regexp/index.spec.js new file mode 100644 index 00000000..c015c268 --- /dev/null +++ b/tests/e2e/test-cases/inline-from-deps-regexp/index.spec.js @@ -0,0 +1 @@ +require('../run-test-case').runTestCase(__dirname); diff --git a/tests/e2e/test-cases/inline-from-deps-regexp/input.ts b/tests/e2e/test-cases/inline-from-deps-regexp/input.ts new file mode 100644 index 00000000..55e565c4 --- /dev/null +++ b/tests/e2e/test-cases/inline-from-deps-regexp/input.ts @@ -0,0 +1,10 @@ +import { Interface, Type, ModuleWithoutQuotes } from 'fake-package'; +import { Derived } from 'fake-types-lib-2'; +import { FooBar } from 'fake-types-lib-3' +import { SomeClass } from 'fake-package/some-class'; + +export type TestType = Interface | Type; +export class MyClass extends SomeClass {} +export type ReExportedTypes = Derived; +export type T = ModuleWithoutQuotes.A; +export type Foo = FooBar; \ No newline at end of file diff --git a/tests/e2e/test-cases/inline-from-deps-regexp/output.d.ts b/tests/e2e/test-cases/inline-from-deps-regexp/output.d.ts new file mode 100644 index 00000000..34ee1e98 --- /dev/null +++ b/tests/e2e/test-cases/inline-from-deps-regexp/output.d.ts @@ -0,0 +1,21 @@ +import { Derived } from 'fake-types-lib-2'; +import { FooBar } from 'fake-types-lib-3'; + +export interface Interface { +} +export type Type = number | string; +declare module ModuleWithoutQuotes { + export type A = string; +} +declare class SomeClass { + private x; + public constructor(); +} +export type TestType = Interface | Type; +export declare class MyClass extends SomeClass { +} +export type ReExportedTypes = Derived; +export type T = ModuleWithoutQuotes.A; +export type Foo = FooBar; + +export {}; diff --git a/tests/unittests/check-schema-match.spec.ts b/tests/unittests/check-schema-match.spec.ts index 6432c4c5..6662db2c 100644 --- a/tests/unittests/check-schema-match.spec.ts +++ b/tests/unittests/check-schema-match.spec.ts @@ -11,8 +11,11 @@ interface TestInterface { requiredBooleanProp: boolean; stringProp?: string; requiredStringProp: string; + stringOrRegExpProp?: string | RegExp; + testObj?: TestObj; testArray?: TestObj[]; stringArray?: string[]; + stringOrRegExpArray?: (string | RegExp)[]; } const testSchema: SchemeDescriptor = { @@ -20,10 +23,29 @@ const testSchema: SchemeDescriptor = { requiredBooleanProp: schemaPrimitiveValues.requiredBoolean, stringProp: schemaPrimitiveValues.string, requiredStringProp: schemaPrimitiveValues.requiredString, + stringOrRegExpProp: schemaPrimitiveValues.stringOrRegExp, + testObj: { + foo: schemaPrimitiveValues.requiredString, + }, testArray: [{ foo: schemaPrimitiveValues.requiredString, }], stringArray: [schemaPrimitiveValues.string], + stringOrRegExpArray: [schemaPrimitiveValues.stringOrRegExp], +}; + +// Test the type definition +// @ts-expect-error -- expected to be unused +const testInvalidSchema: SchemeDescriptor = { + ...testSchema, + // @ts-expect-error -- must be one of the string values + stringProp: schemaPrimitiveValues.stringOrRegExp, + // @ts-expect-error -- must allow string AND regexp + stringOrRegExpProp: schemaPrimitiveValues.string, + // @ts-expect-error -- only one value type in array + stringArray: [schemaPrimitiveValues.string, schemaPrimitiveValues.string], + // @ts-expect-error -- must be an array of stringOrRegExp + stringOrRegExpArray: [schemaPrimitiveValues.string], }; function formatErrors(errors: string[]): string { @@ -37,6 +59,8 @@ describe('checkSchemaMatch', () => { requiredBooleanProp: false, stringProp: 'test', requiredStringProp: 'test', + stringOrRegExpProp: /test/, + testObj: { foo: 'test' }, }; const errors: string[] = []; @@ -48,6 +72,8 @@ describe('checkSchemaMatch', () => { booleanProp: false, requiredBooleanProp: false, requiredStringProp: 'test', + stringOrRegExpProp: 'test', + testObj: undefined, }; const errors: string[] = []; @@ -64,7 +90,7 @@ describe('checkSchemaMatch', () => { assert.strictEqual(checkSchemaMatch(obj, testSchema, errors), true, formatErrors(errors)); }); - it('should return false if object contains exceeded property', () => { + it('should return false if object contains excess property', () => { const obj = { requiredBooleanProp: false, requiredStringProp: 'test', @@ -84,7 +110,18 @@ describe('checkSchemaMatch', () => { assert.strictEqual(checkSchemaMatch(obj, testSchema, errors), false, formatErrors(errors)); }); - it('should return false if both does not have required property and have exceeded property', () => { + it('should return false if nested object does not have required property', () => { + const obj = { + requiredBooleanProp: false, + requiredStringProp: 'test', + testObj: {}, + }; + + const errors: string[] = []; + assert.strictEqual(checkSchemaMatch(obj, testSchema, errors), false, formatErrors(errors)); + }); + + it('should return false if both does not have required property and has excess property', () => { const obj = { requiredBooleanProp: false, fooBar: 123, @@ -94,12 +131,13 @@ describe('checkSchemaMatch', () => { assert.strictEqual(checkSchemaMatch(obj, testSchema, errors), false, formatErrors(errors)); }); - it('should return true for if value is empty array', () => { + it('should return true if value is empty array', () => { const obj: TestInterface = { requiredBooleanProp: false, requiredStringProp: 'test', stringArray: [], testArray: [], + stringOrRegExpArray: [], }; const errors: string[] = []; @@ -115,12 +153,35 @@ describe('checkSchemaMatch', () => { { foo: '3' }, { foo: '2' }, ], + stringOrRegExpArray: ['string1', /string2/], }; const errors: string[] = []; assert.strictEqual(checkSchemaMatch(obj, testSchema, errors), true, formatErrors(errors)); }); + it('should return false if array contains undefined', () => { + const obj = { + requiredBooleanProp: false, + requiredStringProp: 'test', + stringArray: ['', undefined], + }; + + const errors: string[] = []; + assert.strictEqual(checkSchemaMatch(obj, testSchema, errors), false, formatErrors(errors)); + }); + + it('should return false if array contains null', () => { + const obj = { + requiredBooleanProp: false, + requiredStringProp: 'test', + stringArray: ['', null], + }; + + const errors: string[] = []; + assert.strictEqual(checkSchemaMatch(obj, testSchema, errors), false, formatErrors(errors)); + }); + it('should return false if array contains invalid primitive values', () => { const obj = { requiredBooleanProp: false, @@ -132,6 +193,17 @@ describe('checkSchemaMatch', () => { assert.strictEqual(checkSchemaMatch(obj, testSchema, errors), false, formatErrors(errors)); }); + it('should return false if stringOrRegExp array contains invalid primitive values', () => { + const obj = { + requiredBooleanProp: false, + requiredStringProp: 'test', + stringOrRegExpArray: ['', false, 123], + }; + + const errors: string[] = []; + assert.strictEqual(checkSchemaMatch(obj, testSchema, errors), false, formatErrors(errors)); + }); + it('should return false if array contains invalid objects', () => { const obj = { requiredBooleanProp: false, From 87e78a03bea4ac7c4f8cd740748f210ba7d1d84c Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 31 Jul 2025 01:13:08 -0700 Subject: [PATCH 2/2] Fix .d.mts bug --- src/compile-dts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compile-dts.ts b/src/compile-dts.ts index a3e4963e..fb03b2d1 100644 --- a/src/compile-dts.ts +++ b/src/compile-dts.ts @@ -131,7 +131,7 @@ function changeExtensionToDts(fileName: string): string { let ext: ts.Extension | undefined; // `path.extname` doesn't handle `.d.ts` cases (it returns `.ts` instead of `.d.ts`) - if (fileName.endsWith(ts.Extension.Dts)) { + if (fileName.endsWith(ts.Extension.Dts) || fileName.endsWith(ts.Extension.Dmts) || fileName.endsWith(ts.Extension.Dcts)) { return fileName; }