From b53d278f4849d4365badd34bbe75068df270c793 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Fri, 23 Jan 2026 17:36:59 +0200 Subject: [PATCH 1/8] feat(nx-infra-plugin): add create-dual-mode-manifest executor --- .../build/gulp/side-effects-finder.js | 76 ------ packages/nx-infra-plugin/executors.json | 5 + packages/nx-infra-plugin/package.json | 2 + .../executor.e2e.spec.ts | 198 ++++++++++++++++ .../create-dual-mode-manifest/executor.ts | 219 ++++++++++++++++++ .../create-dual-mode-manifest/schema.json | 40 ++++ .../create-dual-mode-manifest/schema.ts | 8 + .../side-effect-finder.ts | 75 ++++++ 8 files changed, 547 insertions(+), 76 deletions(-) delete mode 100644 packages/devextreme/build/gulp/side-effects-finder.js create mode 100644 packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/executor.e2e.spec.ts create mode 100644 packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/executor.ts create mode 100644 packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/schema.json create mode 100644 packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/schema.ts create mode 100644 packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/side-effect-finder.ts diff --git a/packages/devextreme/build/gulp/side-effects-finder.js b/packages/devextreme/build/gulp/side-effects-finder.js deleted file mode 100644 index 2df1e4c1ee35..000000000000 --- a/packages/devextreme/build/gulp/side-effects-finder.js +++ /dev/null @@ -1,76 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -class SideEffectFinder { - checkedPaths = new Map(); - recursiveHandlingPaths = new Set(); - - getModuleSideEffectFiles(moduleFilePath) { - try { - const moduleSideEffectFiles = [...this.findSideEffectsInModule(moduleFilePath)] - .map((importPath) => importPath.replace(/\\/g, '/')); - - return moduleSideEffectFiles; - } catch(e) { - const message = (e instanceof Error) ? e.message : e; - throw (`Exception while check side effects. in ${moduleFilePath} \nException: ` + message + '\n'); - } - } - - findSideEffectsInModule(moduleFilePath) { - let foundPaths = this.checkedPaths.get(moduleFilePath); - - if(!foundPaths) { - const code = fs.readFileSync(moduleFilePath, 'utf8'); - - foundPaths = this.findSideEffectsInImports(moduleFilePath, code); - } - - foundPaths = foundPaths || new Set(); - - this.checkedPaths.set(moduleFilePath, foundPaths); - - return foundPaths; - } - - findSideEffectsInImports(modulePath, code) { - const relativePathRegExp = /['"]\.?\.\/.+['"]/; - const isSideEffectImportRegExp = /^\s*import\s+['"]/ms; - - const imports = code.match(/^\s*import[^;]+;/mg) || []; - let foundPaths = new Set(); - - imports.filter((str) => relativePathRegExp.test(str)) - .forEach((str) => { - let importPath = str.match(relativePathRegExp)[0].replace(/(^['"]|['"]$)/g, ''); - - importPath = path.join(path.dirname(modulePath), importPath) + '.js'; - - if(!fs.existsSync(importPath)) { - importPath = importPath.replace(/\.js$/, '/index.js'); - } - - if(isSideEffectImportRegExp.test(str)) { - foundPaths.add(importPath); - } - - const isInLoop = this.recursiveHandlingPaths.has(importPath); - - if(!isInLoop) { - this.recursiveHandlingPaths.add(importPath); - - foundPaths = new Set([...foundPaths, ...this.findSideEffectsInModule(importPath)]); - - this.recursiveHandlingPaths.delete(importPath); - } - }); - - return foundPaths; - } -} - -module.exports = { - SideEffectFinder -}; diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index 6dcdf6cd6818..c235c8b68ce3 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -69,6 +69,11 @@ "implementation": "./src/executors/babel-transform/executor", "schema": "./src/executors/babel-transform/schema.json", "description": "Transform JavaScript/TypeScript files using Babel with configurable presets" + }, + "create-dual-mode-manifest": { + "implementation": "./src/executors/create-dual-mode-manifest/executor", + "schema": "./src/executors/create-dual-mode-manifest/schema.json", + "description": "Generate package.json files for dual-mode (ESM + CJS) package support" } } } diff --git a/packages/nx-infra-plugin/package.json b/packages/nx-infra-plugin/package.json index 8a2a4662e732..5f7ad3d8c92d 100644 --- a/packages/nx-infra-plugin/package.json +++ b/packages/nx-infra-plugin/package.json @@ -14,6 +14,7 @@ "dependencies": { "fs-extra": "11.2.0", "glob": "11.1.0", + "minimatch": "9.0.5", "normalize-path": "3.0.0", "lodash": "4.17.23", "rimraf": "3.0.2" @@ -69,6 +70,7 @@ "@types/normalize-path": "3.0.2", "@types/node": "18.19.130", "@types/lodash": "4.17.23", + "@types/minimatch": "5.1.2", "prettier": "catalog:tools", "ts-jest": "29.1.3", "typescript": "4.9.5" diff --git a/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/executor.e2e.spec.ts new file mode 100644 index 000000000000..2b13b37dd718 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/executor.e2e.spec.ts @@ -0,0 +1,198 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import executor from './executor'; +import { CreateDualModeManifestExecutorSchema } from './schema'; +import { createTempDir, cleanupTempDir, createMockContext } from '../../utils/test-utils'; +import { writeFileText, readFileText } from '../../utils'; + +describe('CreateDualModeManifest Executor E2E', () => { + let tempDir: string; + let context = createMockContext(); + let projectDir: string; + + beforeEach(async () => { + tempDir = createTempDir('nx-dual-mode-manifest-e2e-'); + context = createMockContext({ root: tempDir }); + projectDir = path.join(tempDir, 'packages', 'test-lib'); + fs.mkdirSync(projectDir, { recursive: true }); + + const esmDir = path.join(projectDir, 'artifacts', 'transpiled-esm-npm', 'esm'); + fs.mkdirSync(esmDir, { recursive: true }); + + const cjsDir = path.join(projectDir, 'artifacts', 'transpiled-esm-npm', 'cjs'); + fs.mkdirSync(cjsDir, { recursive: true }); + + const srcDir = path.join(projectDir, 'js'); + fs.mkdirSync(srcDir, { recursive: true }); + + await writeFileText( + path.join(esmDir, 'core.js'), + `export function coreFunction() { return 'core'; }`, + ); + + await writeFileText( + path.join(esmDir, 'utils.js'), + `export function utilFunction() { return 'util'; }`, + ); + + const esmSubDir = path.join(esmDir, 'ui'); + fs.mkdirSync(esmSubDir, { recursive: true }); + await writeFileText(path.join(esmSubDir, 'index.js'), `export { Button } from './button';`); + await writeFileText(path.join(esmSubDir, 'button.js'), `export const Button = () => {};`); + + await writeFileText( + path.join(cjsDir, 'core.js'), + `"use strict"; module.exports.coreFunction = function() { return 'core'; };`, + ); + await writeFileText( + path.join(cjsDir, 'utils.js'), + `"use strict"; module.exports.utilFunction = function() { return 'util'; };`, + ); + + const cjsSubDir = path.join(cjsDir, 'ui'); + fs.mkdirSync(cjsSubDir, { recursive: true }); + await writeFileText( + path.join(cjsSubDir, 'index.js'), + `"use strict"; var button = require('./button'); module.exports.Button = button.Button;`, + ); + await writeFileText( + path.join(cjsSubDir, 'button.js'), + `"use strict"; module.exports.Button = function() {};`, + ); + await writeFileText( + path.join(srcDir, 'core.d.ts'), + `export declare function coreFunction(): string;`, + ); + + const srcSubDir = path.join(srcDir, 'ui'); + fs.mkdirSync(srcSubDir, { recursive: true }); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + describe('Main functionality', () => { + it('should create package.json files for each JS module', async () => { + const options: CreateDualModeManifestExecutorSchema = { + esmDir: './artifacts/transpiled-esm-npm/esm', + cjsDir: './artifacts/transpiled-esm-npm/cjs', + outputDir: './artifacts/transpiled-esm-npm', + srcDir: './js', + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const outputDir = path.join(projectDir, 'artifacts', 'transpiled-esm-npm'); + + expect(fs.existsSync(path.join(outputDir, 'core', 'package.json'))).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'utils', 'package.json'))).toBe(true); + + expect(fs.existsSync(path.join(outputDir, 'ui', 'package.json'))).toBe(true); + + expect(fs.existsSync(path.join(outputDir, 'ui', 'button', 'package.json'))).toBe(true); + }, 30000); + + it('should include main, module, and sideEffects fields', async () => { + const options: CreateDualModeManifestExecutorSchema = { + esmDir: './artifacts/transpiled-esm-npm/esm', + cjsDir: './artifacts/transpiled-esm-npm/cjs', + outputDir: './artifacts/transpiled-esm-npm', + srcDir: './js', + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const packageJson = JSON.parse( + await readFileText( + path.join(projectDir, 'artifacts', 'transpiled-esm-npm', 'core', 'package.json'), + ), + ); + + expect(packageJson.main).toBeDefined(); + expect(packageJson.main).toContain('cjs'); + expect(packageJson.main).toContain('core.js'); + + expect(packageJson.module).toBeDefined(); + expect(packageJson.module).toContain('esm'); + expect(packageJson.module).toContain('core.js'); + + expect(packageJson.sideEffects).toBe(false); + }, 30000); + + it('should include typings field when .d.ts file exists', async () => { + const options: CreateDualModeManifestExecutorSchema = { + esmDir: './artifacts/transpiled-esm-npm/esm', + cjsDir: './artifacts/transpiled-esm-npm/cjs', + outputDir: './artifacts/transpiled-esm-npm', + srcDir: './js', + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const corePackageJson = JSON.parse( + await readFileText( + path.join(projectDir, 'artifacts', 'transpiled-esm-npm', 'core', 'package.json'), + ), + ); + expect(corePackageJson.typings).toBeDefined(); + expect(corePackageJson.typings).toContain('core.d.ts'); + + const utilsPackageJson = JSON.parse( + await readFileText( + path.join(projectDir, 'artifacts', 'transpiled-esm-npm', 'utils', 'package.json'), + ), + ); + expect(utilsPackageJson.typings).toBeUndefined(); + }, 30000); + }); + + describe('Index file handling', () => { + it('should place package.json in same directory for index.js files', async () => { + const options: CreateDualModeManifestExecutorSchema = { + esmDir: './artifacts/transpiled-esm-npm/esm', + cjsDir: './artifacts/transpiled-esm-npm/cjs', + outputDir: './artifacts/transpiled-esm-npm', + srcDir: './js', + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const indexPackageJson = JSON.parse( + await readFileText( + path.join(projectDir, 'artifacts', 'transpiled-esm-npm', 'ui', 'package.json'), + ), + ); + + expect(indexPackageJson.main).toBeDefined(); + expect(indexPackageJson.module).toBeDefined(); + }, 30000); + }); + + describe('Generated .d.ts files', () => { + it('should include typings for generated .d.ts files', async () => { + const options: CreateDualModeManifestExecutorSchema = { + esmDir: './artifacts/transpiled-esm-npm/esm', + cjsDir: './artifacts/transpiled-esm-npm/cjs', + outputDir: './artifacts/transpiled-esm-npm', + srcDir: './js', + generatedDtsFiles: ['utils.d.ts'], + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const utilsPackageJson = JSON.parse( + await readFileText( + path.join(projectDir, 'artifacts', 'transpiled-esm-npm', 'utils', 'package.json'), + ), + ); + expect(utilsPackageJson.typings).toBeDefined(); + expect(utilsPackageJson.typings).toContain('utils.d.ts'); + }, 30000); + }); +}); diff --git a/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/executor.ts b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/executor.ts new file mode 100644 index 000000000000..0ce9a4e6c8c3 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/executor.ts @@ -0,0 +1,219 @@ +import { PromiseExecutor, logger } from '@nx/devkit'; +import * as path from 'path'; +import { glob } from 'glob'; +import { minimatch } from 'minimatch'; +import { CreateDualModeManifestExecutorSchema } from './schema'; +import { SideEffectFinder } from './side-effect-finder'; +import { resolveProjectPath, normalizeGlobPathForWindows } from '../../utils/path-resolver'; +import { isWindowsOS } from '../../utils/common'; +import { logError } from '../../utils/error-handler'; +import { exists, ensureDir, writeFileText } from '../../utils/file-operations'; + +const ERROR_MESSAGES = { + ESM_DIR_NOT_FOUND: (dir: string) => `ESM directory does not exist: ${dir}`, + CJS_DIR_NOT_FOUND: (dir: string) => `CJS directory does not exist: ${dir}`, + FAILED_TO_CREATE_MANIFEST: 'Failed to create dual-mode manifest files', +} as const; + +function normalizePackagePath(p: string): string { + return p.replace(/\\/g, '/'); +} + +function createModuleConfig( + fileName: string, + fileDir: string, + esmFilePath: string, + srcDir: string, + generatedDtsFiles: string[], + sideEffectFinder: SideEffectFinder, +): string { + const isIndex = fileName === 'index.js'; + const relative = path.join('./', fileDir.replace(srcDir, ''), fileName); + const currentPath = isIndex ? path.join(relative, '../') : relative; + + const esmFile = path.relative(currentPath, path.join('./esm', relative)); + const cjsFile = path.relative(currentPath, path.join('./cjs', relative)); + + const dtsRelative = relative.replace(/\.js$/, '.d.ts'); + const realDtsPath = path.join(fileDir, fileName.replace(/\.js$/, '.d.ts')); + const hasGeneratedDts = generatedDtsFiles.includes( + normalizePackagePath(dtsRelative.replace(/^\.\//, '')), + ); + + const relativeEsmBase = normalizePackagePath(esmFile).match(/^.*\/esm\//)?.[0] || './esm/'; + let sideEffectFiles: string[] | false = false; + + try { + const moduleSideEffects = sideEffectFinder.getModuleSideEffectFiles(esmFilePath); + if (moduleSideEffects.length > 0) { + sideEffectFiles = moduleSideEffects.map((importPath) => + importPath.replace(/^.*\/esm\//, relativeEsmBase), + ); + } + } catch (e) { + logger.verbose(`Side effect analysis failed for ${esmFilePath}: ${e}`); + } + + const result: Record = { + sideEffects: sideEffectFiles, + main: normalizePackagePath(cjsFile), + module: normalizePackagePath(esmFile), + }; + + const hasRealDts = require('fs').existsSync(realDtsPath); + const hasDts = hasRealDts || hasGeneratedDts; + + if (hasDts) { + const typingFile = fileName.replace(/\.js$/, '.d.ts'); + result['typings'] = `${isIndex ? './' : '../'}${typingFile}`; + } + + return JSON.stringify(result, null, 2); +} + +function getPackageJsonOutputPath( + fileName: string, + fileDir: string, + srcDir: string, + outputDir: string, +): string { + const relativePath = fileDir.replace(srcDir, ''); + const baseName = path.basename(fileName, '.js'); + const isIndex = fileName === 'index.js'; + + if (isIndex) { + return path.join(outputDir, relativePath, 'package.json'); + } else { + return path.join(outputDir, relativePath, baseName, 'package.json'); + } +} + +async function validateDirectories(esmDir: string, cjsDir: string): Promise { + if (!(await exists(esmDir))) { + throw new Error(ERROR_MESSAGES.ESM_DIR_NOT_FOUND(esmDir)); + } + + if (!(await exists(cjsDir))) { + throw new Error(ERROR_MESSAGES.CJS_DIR_NOT_FOUND(cjsDir)); + } +} + +async function discoverJsFiles(esmDir: string): Promise { + const pattern = path.join(esmDir, '**/*.js'); + const globPattern = isWindowsOS() ? normalizeGlobPathForWindows(pattern) : pattern; + + return glob(globPattern, { + nodir: true, + ignore: ['**/node_modules/**'], + }); +} + +function shouldExcludeFile(relativeFilePath: string, excludePatterns: string[]): boolean { + return excludePatterns.some((pattern) => minimatch(relativeFilePath, pattern, { dot: true })); +} + +async function processFile( + file: string, + esmDir: string, + srcDir: string, + outputDir: string, + excludePatterns: string[], + generatedDtsFiles: string[], + sideEffectFinder: SideEffectFinder, +): Promise { + const fileName = path.basename(file); + const fileDir = path.dirname(file); + + const relativeFromEsm = path.relative(esmDir, fileDir); + const relativeFilePath = normalizePackagePath(path.join(relativeFromEsm, fileName)); + + if (shouldExcludeFile(relativeFilePath, excludePatterns)) { + logger.verbose(`Skipping excluded file: ${relativeFilePath}`); + return false; + } + + const correspondingSrcDir = path.join(srcDir, relativeFromEsm); + + const moduleConfig = createModuleConfig( + fileName, + correspondingSrcDir, + file, + srcDir, + generatedDtsFiles, + sideEffectFinder, + ); + + const packageJsonPath = getPackageJsonOutputPath( + fileName, + correspondingSrcDir, + srcDir, + outputDir, + ); + + await ensureDir(path.dirname(packageJsonPath)); + await writeFileText(packageJsonPath, moduleConfig); + + logger.verbose(`Created: ${path.relative(outputDir, packageJsonPath)}`); + return true; +} + +const runExecutor: PromiseExecutor = async ( + options, + context, +) => { + const projectRoot = resolveProjectPath(context); + + const esmDir = path.resolve(projectRoot, options.esmDir); + const cjsDir = path.resolve(projectRoot, options.cjsDir); + const outputDir = path.resolve(projectRoot, options.outputDir); + const srcDir = path.resolve(projectRoot, options.srcDir); + const excludePatterns = options.excludePatterns || []; + const generatedDtsFiles = options.generatedDtsFiles || []; + + logger.verbose(`Creating dual-mode manifest files...`); + logger.verbose(` ESM dir: ${esmDir}`); + logger.verbose(` CJS dir: ${cjsDir}`); + logger.verbose(` Output dir: ${outputDir}`); + logger.verbose(` Source dir: ${srcDir}`); + logger.verbose(` Exclude patterns: ${excludePatterns.join(', ') || '(none)'}`); + + try { + await validateDirectories(esmDir, cjsDir); + + const files = await discoverJsFiles(esmDir); + + if (files.length === 0) { + logger.warn(`No JS files found in ESM directory: ${esmDir}`); + return { success: true }; + } + + logger.verbose(`Found ${files.length} JS files to process`); + + const sideEffectFinder = new SideEffectFinder(); + let createdCount = 0; + + for (const file of files) { + const created = await processFile( + file, + esmDir, + srcDir, + outputDir, + excludePatterns, + generatedDtsFiles, + sideEffectFinder, + ); + if (created) { + createdCount++; + } + } + + logger.info(`Created ${createdCount} package.json manifest files`); + + return { success: true }; + } catch (error) { + logError(ERROR_MESSAGES.FAILED_TO_CREATE_MANIFEST, error); + return { success: false }; + } +}; + +export default runExecutor; diff --git a/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/schema.json b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/schema.json new file mode 100644 index 000000000000..333abe0493c0 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/schema", + "type": "object", + "title": "Create Dual-Mode Manifest Executor", + "description": "Generate package.json files for dual-mode (ESM + CJS) package support with main, module, typings, and sideEffects fields", + "properties": { + "esmDir": { + "type": "string", + "description": "Directory containing ESM transpiled files (e.g., './artifacts/transpiled-esm-npm/esm')" + }, + "cjsDir": { + "type": "string", + "description": "Directory containing CJS transpiled files (e.g., './artifacts/transpiled-esm-npm/cjs')" + }, + "outputDir": { + "type": "string", + "description": "Base output directory where package.json files will be created (e.g., './artifacts/transpiled-esm-npm')" + }, + "srcDir": { + "type": "string", + "description": "Source directory for .d.ts file lookup (e.g., './js')" + }, + "excludePatterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns to exclude from processing (matched against relative path from esmDir)" + }, + "generatedDtsFiles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of generated .d.ts files that don't exist in srcDir but are generated during build (paths relative to srcDir)" + } + }, + "required": ["esmDir", "cjsDir", "outputDir", "srcDir"], + "additionalProperties": false +} diff --git a/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/schema.ts b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/schema.ts new file mode 100644 index 000000000000..8f9bf7159e78 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/schema.ts @@ -0,0 +1,8 @@ +export interface CreateDualModeManifestExecutorSchema { + esmDir: string; + cjsDir: string; + outputDir: string; + srcDir: string; + excludePatterns?: string[]; + generatedDtsFiles?: string[]; +} diff --git a/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/side-effect-finder.ts b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/side-effect-finder.ts new file mode 100644 index 000000000000..f2bc64430fb0 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/side-effect-finder.ts @@ -0,0 +1,75 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export class SideEffectFinder { + private checkedPaths: Map> = new Map(); + private recursiveHandlingPaths: Set = new Set(); + + getModuleSideEffectFiles(moduleFilePath: string): string[] { + try { + const moduleSideEffectFiles = [...this.findSideEffectsInModule(moduleFilePath)].map( + (importPath) => importPath.replace(/\\/g, '/'), + ); + return moduleSideEffectFiles; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new Error( + `Exception while checking side effects in ${moduleFilePath}\nException: ${message}`, + ); + } + } + + private findSideEffectsInModule(moduleFilePath: string): Set { + let foundPaths = this.checkedPaths.get(moduleFilePath); + + if (!foundPaths) { + if (!fs.existsSync(moduleFilePath)) { + return new Set(); + } + + const code = fs.readFileSync(moduleFilePath, 'utf8'); + foundPaths = this.findSideEffectsInImports(moduleFilePath, code); + } + + foundPaths = foundPaths || new Set(); + this.checkedPaths.set(moduleFilePath, foundPaths); + + return foundPaths; + } + + private findSideEffectsInImports(modulePath: string, code: string): Set { + const relativePathRegExp = /['"]\.?\.\/.+['"]/; + const isSideEffectImportRegExp = /^\s*import\s+['"]/ms; + + const imports = code.match(/^\s*import[^;]+;/gm) || []; + let foundPaths = new Set(); + + imports + .filter((str) => relativePathRegExp.test(str)) + .forEach((str) => { + const match = str.match(relativePathRegExp); + if (!match) return; + + let importPath = match[0].replace(/(^['"]|['"]$)/g, ''); + importPath = path.join(path.dirname(modulePath), importPath) + '.js'; + + if (!fs.existsSync(importPath)) { + importPath = importPath.replace(/\.js$/, '/index.js'); + } + + if (isSideEffectImportRegExp.test(str)) { + foundPaths.add(importPath); + } + + const isInLoop = this.recursiveHandlingPaths.has(importPath); + + if (!isInLoop) { + this.recursiveHandlingPaths.add(importPath); + foundPaths = new Set([...foundPaths, ...this.findSideEffectsInModule(importPath)]); + this.recursiveHandlingPaths.delete(importPath); + } + }); + + return foundPaths; + } +} From 100a2eee92feb3a2260e3b77f4bfa3b87d564a7b Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Fri, 23 Jan 2026 17:36:59 +0200 Subject: [PATCH 2/8] feat(nx-infra-plugin): enhance build-typescript executor with new options --- packages/nx-infra-plugin/package.json | 3 +- .../build-typescript/executor.e2e.spec.ts | 121 ++++---- .../executors/build-typescript/executor.ts | 291 +++++++++++++----- .../executors/build-typescript/schema.json | 19 +- .../src/executors/build-typescript/schema.ts | 2 + pnpm-lock.yaml | 3 + 6 files changed, 308 insertions(+), 131 deletions(-) diff --git a/packages/nx-infra-plugin/package.json b/packages/nx-infra-plugin/package.json index 5f7ad3d8c92d..7641ee4a615c 100644 --- a/packages/nx-infra-plugin/package.json +++ b/packages/nx-infra-plugin/package.json @@ -17,7 +17,8 @@ "minimatch": "9.0.5", "normalize-path": "3.0.0", "lodash": "4.17.23", - "rimraf": "3.0.2" + "rimraf": "3.0.2", + "tsc-alias": "1.8.10" }, "peerDependencies": { "@babel/core": ">=7.0.0", diff --git a/packages/nx-infra-plugin/src/executors/build-typescript/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/build-typescript/executor.e2e.spec.ts index 11c77f2b9e7b..46f72350a0f9 100644 --- a/packages/nx-infra-plugin/src/executors/build-typescript/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/build-typescript/executor.e2e.spec.ts @@ -8,24 +8,27 @@ import { writeFileText, writeJson, readFileText } from '../../utils'; describe('BuildTypescriptExecutor E2E', () => { let tempDir: string; let context = createMockContext(); + let projectDir: string; beforeEach(async () => { tempDir = createTempDir('nx-build-ts-e2e-'); context = createMockContext({ root: tempDir }); - - const projectDir = path.join(tempDir, 'packages', 'test-lib'); + projectDir = path.join(tempDir, 'packages', 'test-lib'); const srcDir = path.join(projectDir, 'src'); fs.mkdirSync(srcDir, { recursive: true }); + const utilsDir = path.join(srcDir, 'utils'); + fs.mkdirSync(utilsDir, { recursive: true }); + await writeFileText( - path.join(srcDir, 'index.ts'), - `export function hello(name: string): string {\n return \`Hello, \${name}!\`;\n}\n`, + path.join(utilsDir, 'index.ts'), + `export const add = (a: number, b: number): number => a + b;\n`, ); await writeFileText( - path.join(srcDir, 'utils.ts'), - `export const add = (a: number, b: number): number => a + b;\n`, + path.join(srcDir, 'index.ts'), + `import { add } from '@lib/utils';\nexport function hello(name: string): string {\n return \`Hello, \${name}! Sum: \${add(1, 2)}\`;\n}\n`, ); fs.mkdirSync(path.join(srcDir, '__tests__'), { recursive: true }); @@ -46,6 +49,10 @@ describe('BuildTypescriptExecutor E2E', () => { strict: true, esModuleInterop: true, skipLibCheck: true, + baseUrl: './src', + paths: { + '@lib/*': ['./*'], + }, }, include: ['src/**/*'], exclude: ['**/*.spec.ts', '**/__tests__/**'], @@ -63,6 +70,10 @@ describe('BuildTypescriptExecutor E2E', () => { strict: true, esModuleInterop: true, skipLibCheck: true, + baseUrl: './src', + paths: { + '@lib/*': ['./*'], + }, }, include: ['src/**/*'], exclude: ['**/*.spec.ts', '**/__tests__/**'], @@ -73,92 +84,92 @@ describe('BuildTypescriptExecutor E2E', () => { cleanupTempDir(tempDir); }); - describe('ESM build', () => { - it('should compile TypeScript to ESM successfully', async () => { + describe.each([ + { + moduleType: 'esm' as const, + tsconfig: './tsconfig.esm.json', + outDir: './npm/esm', + expectedExport: 'export', + unexpectedExport: 'module.exports', + }, + { + moduleType: 'cjs' as const, + tsconfig: './tsconfig.json', + outDir: './npm/cjs', + expectedExport: 'exports', + unexpectedExport: 'export function', + }, + ])('$moduleType build', ({ moduleType, tsconfig, outDir, expectedExport, unexpectedExport }) => { + it('should compile and exclude test files', async () => { const options: BuildTypescriptExecutorSchema = { - module: 'esm', + module: moduleType, srcPattern: './src/**/*.{ts,tsx}', excludePatterns: ['./src/**/__tests__/**/*'], - tsconfig: './tsconfig.esm.json', - outDir: './npm/esm', + tsconfig, + outDir, }; const result = await executor(options, context); expect(result.success).toBe(true); - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const outDir = path.join(projectDir, 'npm', 'esm'); + const outputDir = path.join(projectDir, outDir.replace('./', '')); + + expect(fs.existsSync(path.join(outputDir, 'index.js'))).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'utils', 'index.js'))).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'index.d.ts'))).toBe(true); - expect(fs.existsSync(path.join(outDir, 'index.js'))).toBe(true); - expect(fs.existsSync(path.join(outDir, 'utils.js'))).toBe(true); + expect(fs.existsSync(path.join(outputDir, '__tests__'))).toBe(false); - expect(fs.existsSync(path.join(outDir, 'index.d.ts'))).toBe(true); - expect(fs.existsSync(path.join(outDir, 'utils.d.ts'))).toBe(true); + const indexContent = await readFileText(path.join(outputDir, 'index.js')); + expect(indexContent).toContain(expectedExport); + expect(indexContent).not.toContain(unexpectedExport); - const indexContent = await readFileText(path.join(outDir, 'index.js')); - expect(indexContent).toContain('export'); - expect(indexContent).not.toContain('module.exports'); + expect(indexContent).toContain('@lib/utils'); }, 10000); - }); - describe('CJS build', () => { - it('should compile TypeScript to CommonJS successfully', async () => { + it('should resolve path aliases when resolvePaths is enabled', async () => { const options: BuildTypescriptExecutorSchema = { - module: 'cjs', + module: moduleType, srcPattern: './src/**/*.{ts,tsx}', excludePatterns: ['./src/**/__tests__/**/*'], - tsconfig: './tsconfig.json', - outDir: './npm/cjs', + tsconfig, + outDir, + resolvePaths: true, + resolvePathsBaseDir: './src', }; const result = await executor(options, context); expect(result.success).toBe(true); - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const outDir = path.join(projectDir, 'npm', 'cjs'); - - expect(fs.existsSync(path.join(outDir, 'index.js'))).toBe(true); - expect(fs.existsSync(path.join(outDir, 'utils.js'))).toBe(true); + const outputDir = path.join(projectDir, outDir.replace('./', '')); + const indexContent = await readFileText(path.join(outputDir, 'index.js')); - const indexContent = await readFileText(path.join(outDir, 'index.js')); - expect(indexContent).toContain('exports'); - expect(indexContent).not.toContain('export function'); + expect(indexContent).not.toContain('@lib/utils'); + expect(indexContent).toContain('./utils'); }, 10000); }); describe('Error handling', () => { - it('should handle missing tsconfig file', async () => { - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - + it('should fail when tsconfig is missing', async () => { fs.unlinkSync(path.join(projectDir, 'tsconfig.esm.json')); - const options: BuildTypescriptExecutorSchema = { - module: 'esm', - tsconfig: './tsconfig.esm.json', - }; - - const result = await executor(options, context); + const result = await executor({ tsconfig: './tsconfig.esm.json' }, context); expect(result.success).toBe(false); }); - it('should handle empty source directory', async () => { - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - - fs.rmSync(path.join(projectDir, 'src'), { recursive: true, force: true }); - fs.mkdirSync(path.join(projectDir, 'src')); - - fs.writeFileSync(path.join(projectDir, 'src', 'empty.ts'), ''); + it('should fail when no files match pattern', async () => { + const result = await executor({ srcPattern: './nonexistent/**/*.ts' }, context); - const options: BuildTypescriptExecutorSchema = { - module: 'esm', - }; + expect(result.success).toBe(false); + }); - const result = await executor(options, context); + it('should fail when resolvePaths is true but resolvePathsBaseDir is missing', async () => { + const result = await executor({ resolvePaths: true }, context); - expect(result).toHaveProperty('success'); + expect(result.success).toBe(false); }); }); }); diff --git a/packages/nx-infra-plugin/src/executors/build-typescript/executor.ts b/packages/nx-infra-plugin/src/executors/build-typescript/executor.ts index 0cf58cd70a33..dd26950c6c45 100644 --- a/packages/nx-infra-plugin/src/executors/build-typescript/executor.ts +++ b/packages/nx-infra-plugin/src/executors/build-typescript/executor.ts @@ -2,42 +2,160 @@ import { PromiseExecutor, logger } from '@nx/devkit'; import * as ts from 'typescript'; import * as path from 'path'; import { glob } from 'glob'; +import { prepareSingleFileReplaceTscAliasPaths } from 'tsc-alias'; import { BuildTypescriptExecutorSchema } from './schema'; import { TsConfig, CompilerOptions } from '../../utils/types'; import { resolveProjectPath, normalizeGlobPathForWindows } from '../../utils/path-resolver'; import { isWindowsOS } from '../../utils/common'; import { logError } from '../../utils/error-handler'; -import { readFileText, exists, ensureDir } from '../../utils/file-operations'; +import { exists, ensureDir, writeFileText } from '../../utils/file-operations'; -const MODULE_TYPE_ESM = 'esm'; -const MODULE_TYPE_CJS = 'cjs'; +type AliasTranspileFunc = (filePath: string, fileContents: string) => string; -const DEFAULT_MODULE_TYPE = MODULE_TYPE_ESM; -const DEFAULT_TSCONFIG_CJS = './tsconfig.json'; -const DEFAULT_TSCONFIG_ESM = './tsconfig.esm.json'; -const DEFAULT_OUT_DIR_CJS = './npm/cjs'; -const DEFAULT_OUT_DIR_ESM = './npm/esm'; +const DEFAULT_MODULE_TYPE = 'esm'; +const DEFAULT_TSCONFIG = './tsconfig.esm.json'; +const DEFAULT_OUT_DIR = './npm/esm'; const DEFAULT_SRC_PATTERN = './src/**/*.{ts,tsx}'; -const ERROR_COMPILATION_FAILED = 'Compilation failed'; - const NEWLINE_CHAR = '\n'; +const ERROR_MESSAGES = { + COMPILATION_FAILED: 'Compilation failed', + TSCONFIG_NOT_FOUND: (filePath: string) => `TypeScript config file not found: ${filePath}`, + TSCONFIG_PARSE_ERROR: (message: string) => `Error reading tsconfig: ${message}`, + NO_SOURCE_FILES: (pattern: string) => `No source files matched pattern: ${pattern}`, + RESOLVE_PATHS_REQUIRES_BASE_DIR: 'resolvePathsBaseDir is required when resolvePaths is enabled', + BUILD_FAILED: (moduleType: string) => `Failed to build ${moduleType}`, +} as const; + +interface ResolvedConfig { + projectRoot: string; + moduleType: string; + tsconfigPath: string; + outDir: string; + srcPattern: string; + excludePatterns: string[]; + resolvePaths: boolean; + resolvePathsBaseDir?: string; +} + +interface EmitProgramResult { + success: boolean; +} + +interface EmitOptions { + aliasTranspileFunc?: AliasTranspileFunc; + outDir: string; + aliasPath?: string; +} + +function resolveExecutorConfig( + options: BuildTypescriptExecutorSchema, + context: Parameters>[1], +): ResolvedConfig { + const projectRoot = resolveProjectPath(context); + + return { + projectRoot, + moduleType: options.module || DEFAULT_MODULE_TYPE, + tsconfigPath: path.join(projectRoot, options.tsconfig || DEFAULT_TSCONFIG), + outDir: path.join(projectRoot, options.outDir || DEFAULT_OUT_DIR), + srcPattern: options.srcPattern || DEFAULT_SRC_PATTERN, + excludePatterns: options.excludePatterns || [], + resolvePaths: options.resolvePaths ?? false, + resolvePathsBaseDir: options.resolvePathsBaseDir + ? path.join(projectRoot, options.resolvePathsBaseDir) + : undefined, + }; +} + +function validateOptions(config: ResolvedConfig): void { + if (config.resolvePaths && !config.resolvePathsBaseDir) { + throw new Error(ERROR_MESSAGES.RESOLVE_PATHS_REQUIRES_BASE_DIR); + } +} + +async function createAliasTranspileFunc( + tsconfigPath: string, + aliasRoot: string, +): Promise { + const transpileFunc = await prepareSingleFileReplaceTscAliasPaths({ + configFile: tsconfigPath, + outDir: aliasRoot, + }); + + return (filePath: string, fileContents: string): string => { + return transpileFunc({ fileContents, filePath }); + }; +} + async function loadTsConfig( tsconfigPath: string, ): Promise<{ content: TsConfig; compilerOptions: CompilerOptions }> { if (!(await exists(tsconfigPath))) { - throw new Error(`TypeScript config file not found: ${tsconfigPath}`); + throw new Error(ERROR_MESSAGES.TSCONFIG_NOT_FOUND(tsconfigPath)); } - const tsconfigContentRaw = await readFileText(tsconfigPath); - const content = JSON.parse(tsconfigContentRaw) as TsConfig; + const { config, error } = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + + if (error) { + const message = ts.flattenDiagnosticMessageText(error.messageText, NEWLINE_CHAR); + throw new Error(ERROR_MESSAGES.TSCONFIG_PARSE_ERROR(message)); + } + + const content = config as TsConfig; return { content, compilerOptions: content.compilerOptions || {}, }; } +async function resolveSourceFiles( + projectRoot: string, + srcPattern: string, + excludePatterns: string[], +): Promise { + const globPattern = isWindowsOS() + ? normalizeGlobPathForWindows(path.join(projectRoot, srcPattern)) + : path.join(projectRoot, srcPattern); + + const resolvedExcludes = excludePatterns.map((pattern) => { + const result = path.join(projectRoot, pattern); + return isWindowsOS() ? normalizeGlobPathForWindows(result) : result; + }); + + const files = await glob(globPattern, { + absolute: true, + nodir: true, + ignore: resolvedExcludes, + }); + + if (files.length === 0) { + throw new Error(ERROR_MESSAGES.NO_SOURCE_FILES(srcPattern)); + } + + return files; +} + +function buildCompilerOptions( + tsconfigContent: TsConfig, + tsconfigPath: string, + outDir: string, + resolvePaths: boolean, +): ts.CompilerOptions { + const parsedConfig = ts.parseJsonConfigFileContent( + tsconfigContent, + ts.sys, + path.dirname(tsconfigPath), + ); + + return { + ...parsedConfig.options, + outDir, + paths: resolvePaths ? parsedConfig.options.paths : {}, + }; +} + function formatDiagnostics(diagnostics: ts.Diagnostic[]): string[] { return diagnostics.map((diagnostic) => { if (diagnostic.file) { @@ -49,82 +167,113 @@ function formatDiagnostics(diagnostics: ts.Diagnostic[]): string[] { }); } -function compile(sourceFiles: string[], compilerOptions: ts.CompilerOptions): ts.Program { - return ts.createProgram(sourceFiles, compilerOptions); +async function emitWithAliasResolution( + program: ts.Program, + options: EmitOptions, +): Promise<{ success: boolean; diagnostics: ts.Diagnostic[] }> { + const { aliasTranspileFunc, outDir, aliasPath } = options; + const emittedFiles: Array<{ path: string; content: string }> = []; + + const result = program.emit(undefined, (filePath, fileData) => { + let finalContent = fileData; + + if (aliasTranspileFunc && aliasPath) { + const normalizedFilePath = filePath.replace(outDir, aliasPath); + finalContent = aliasTranspileFunc(normalizedFilePath, fileData); + } + + emittedFiles.push({ path: filePath, content: finalContent }); + }); + + for (const file of emittedFiles) { + const dir = path.dirname(file.path); + await ensureDir(dir); + await writeFileText(file.path, file.content); + } + + const diagnostics = ts.getPreEmitDiagnostics(program).concat(result.diagnostics); + return { success: !result.emitSkipped, diagnostics }; } -const runExecutor: PromiseExecutor = async (options, context) => { - const absoluteProjectRoot = resolveProjectPath(context); - const module = options.module || DEFAULT_MODULE_TYPE; +async function emitWithPathAliasResolution( + program: ts.Program, + config: ResolvedConfig, +): Promise { + const aliasTranspileFunc = await createAliasTranspileFunc( + config.tsconfigPath, + config.resolvePathsBaseDir!, + ); - const defaultTsconfigPath = - module === MODULE_TYPE_CJS ? DEFAULT_TSCONFIG_CJS : DEFAULT_TSCONFIG_ESM; - const tsconfigPath = path.join(absoluteProjectRoot, options.tsconfig || defaultTsconfigPath); + logger.verbose(`Path alias resolution enabled with base dir: ${config.resolvePathsBaseDir}`); - const defaultOutDir = module === MODULE_TYPE_CJS ? DEFAULT_OUT_DIR_CJS : DEFAULT_OUT_DIR_ESM; - const outDir = path.join(absoluteProjectRoot, options.outDir || defaultOutDir); + const { success, diagnostics } = await emitWithAliasResolution(program, { + aliasTranspileFunc, + outDir: config.outDir, + aliasPath: config.resolvePathsBaseDir, + }); + + if (!success) { + logger.error(ERROR_MESSAGES.COMPILATION_FAILED); + formatDiagnostics(diagnostics).forEach((msg) => logger.error(msg)); + } + + return { success }; +} + +function emitStandard(program: ts.Program): EmitProgramResult { + const result = program.emit(); + + if (result.emitSkipped) { + logger.error(ERROR_MESSAGES.COMPILATION_FAILED); + const diagnostics = ts.getPreEmitDiagnostics(program).concat(result.diagnostics); + formatDiagnostics(diagnostics).forEach((msg) => logger.error(msg)); + return { success: false }; + } + + return { success: true }; +} + +const runExecutor: PromiseExecutor = async (options, context) => { + const config = resolveExecutorConfig(options, context); try { - const { content: tsconfigContent, compilerOptions } = await loadTsConfig(tsconfigPath); - compilerOptions.outDir = outDir; - await ensureDir(outDir); - - const srcPattern = options.srcPattern || DEFAULT_SRC_PATTERN; - const globPattern = isWindowsOS() - ? normalizeGlobPathForWindows(path.join(absoluteProjectRoot, srcPattern)) - : path.join(absoluteProjectRoot, srcPattern); - - const excludePatterns = options.excludePatterns - ? options.excludePatterns.map((pattern) => { - const result = path.join(absoluteProjectRoot, pattern); - - if (isWindowsOS()) { - return normalizeGlobPathForWindows(result); - } - - return result; - }) - : []; - - const sourceFiles = await glob(globPattern, { - absolute: true, - nodir: true, - ignore: excludePatterns, - }); - - if (sourceFiles.length === 0) { - throw new Error(`No source files matched pattern: ${srcPattern}`); - } + validateOptions(config); - logger.verbose(`Building ${module.toUpperCase()} for ${sourceFiles.length} source files...`); + const { content: tsconfigContent, compilerOptions } = await loadTsConfig(config.tsconfigPath); + compilerOptions.outDir = config.outDir; + await ensureDir(config.outDir); - const parsedConfig = ts.parseJsonConfigFileContent( - tsconfigContent, - ts.sys, - path.dirname(tsconfigPath), + const sourceFiles = await resolveSourceFiles( + config.projectRoot, + config.srcPattern, + config.excludePatterns, ); - const finalCompilerOptions: ts.CompilerOptions = { - ...parsedConfig.options, - outDir: compilerOptions.outDir, - paths: {}, - }; + logger.verbose( + `Building ${config.moduleType.toUpperCase()} for ${sourceFiles.length} files...`, + ); - const program = compile(sourceFiles, finalCompilerOptions); - const result = program.emit(); + const finalCompilerOptions = buildCompilerOptions( + tsconfigContent, + config.tsconfigPath, + config.outDir, + config.resolvePaths, + ); - if (result.emitSkipped) { - logger.error(ERROR_COMPILATION_FAILED); - const diagnostics = ts.getPreEmitDiagnostics(program).concat(result.diagnostics); + const program = ts.createProgram(sourceFiles, finalCompilerOptions); + const emitResult = + config.resolvePaths && config.resolvePathsBaseDir + ? await emitWithPathAliasResolution(program, config) + : emitStandard(program); - formatDiagnostics(diagnostics).forEach((msg) => logger.error(msg)); + if (!emitResult.success) { return { success: false }; } - logger.verbose(`✓ ${module.toUpperCase()} build completed successfully`); + logger.verbose(`✓ ${config.moduleType.toUpperCase()} build completed successfully`); return { success: true }; } catch (error) { - logError(`Failed to build ${module.toUpperCase()}`, error); + logError(ERROR_MESSAGES.BUILD_FAILED(config.moduleType.toUpperCase()), error); return { success: false }; } }; diff --git a/packages/nx-infra-plugin/src/executors/build-typescript/schema.json b/packages/nx-infra-plugin/src/executors/build-typescript/schema.json index 4e4a6b930cca..69d9b88102ad 100644 --- a/packages/nx-infra-plugin/src/executors/build-typescript/schema.json +++ b/packages/nx-infra-plugin/src/executors/build-typescript/schema.json @@ -6,13 +6,13 @@ "properties": { "module": { "type": "string", - "description": "Target module format. 'esm' generates ES modules with import/export statements, 'cjs' generates CommonJS with require/module.exports. This affects output file structure, TypeScript compiler module settings, and determines the default tsconfig and output directory.", + "description": "Target module format", "enum": ["cjs", "esm"], "default": "esm" }, "srcPattern": { "type": "string", - "description": "Glob pattern for source files to include in compilation. Supports standard glob syntax with wildcards. Default pattern includes all .ts and .tsx files in src directory recursively.", + "description": "Glob pattern for source files to include in compilation. Supports standard glob syntax with wildcards.", "default": "./src/**/*.{ts,tsx}" }, "excludePatterns": { @@ -24,11 +24,22 @@ }, "tsconfig": { "type": "string", - "description": "Path to TypeScript configuration file relative to project root. If not specified, defaults to './tsconfig.json' for CJS builds or './tsconfig.esm.json' for ESM builds. The tsconfig should contain appropriate module and target settings for the desired output format." + "default": "./tsconfig.esm.json", + "description": "Path to TypeScript configuration file relative to project root." }, "outDir": { "type": "string", - "description": "Output directory path relative to project root where compiled JavaScript and declaration files will be written. If not specified, defaults to './npm/cjs' for CJS builds or './npm/esm' for ESM builds. Directory will be created if it doesn't exist." + "default": "./npm/esm", + "description": "Output directory path relative to project root where compiled JavaScript and declaration files will be written. Directory will be created if it doesn't exist." + }, + "resolvePaths": { + "type": "boolean", + "default": false, + "description": "Enable path alias resolution using tsc-alias. When true, transforms import paths like '@ts/*' to relative paths based on tsconfig paths configuration. Requires resolvePathsBaseDir to be set." + }, + "resolvePathsBaseDir": { + "type": "string", + "description": "Base directory for path alias resolution (used as aliasRoot for tsc-alias). Relative to project root. Required when resolvePaths is true. Example: './js' for DevExtreme's __internal TypeScript compilation." } }, "required": [], diff --git a/packages/nx-infra-plugin/src/executors/build-typescript/schema.ts b/packages/nx-infra-plugin/src/executors/build-typescript/schema.ts index 933163f7a5bc..1fa42424af72 100644 --- a/packages/nx-infra-plugin/src/executors/build-typescript/schema.ts +++ b/packages/nx-infra-plugin/src/executors/build-typescript/schema.ts @@ -4,4 +4,6 @@ export interface BuildTypescriptExecutorSchema { excludePatterns?: string[]; tsconfig?: string; outDir?: string; + resolvePaths?: boolean; + resolvePathsBaseDir?: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e35589b56bc4..fa0f4f6a49c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2295,6 +2295,9 @@ importers: rimraf: specifier: 3.0.2 version: 3.0.2 + tsc-alias: + specifier: 1.8.10 + version: 1.8.10 devDependencies: '@types/babel__core': specifier: 7.20.5 From bba8544b37b160fad1b95c88c583ebcd487993d3 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 26 Jan 2026 13:56:23 +0200 Subject: [PATCH 3/8] chore(devextreme): replace gulp wrappers with native nx executors --- packages/devextreme/project.json | 35 ++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index bb9e851651a1..0fe94944bf82 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -142,10 +142,14 @@ } }, "build:ts:internal": { - "executor": "nx:run-commands", + "executor": "devextreme-nx-infra-plugin:build-typescript", "options": { - "command": "gulp ts-compile-internal", - "cwd": "{projectRoot}" + "srcPattern": "./js/__internal/**/*.{ts,tsx}", + "excludePatterns": ["./js/__internal/**/__tests__/**/*"], + "tsconfig": "./js/__internal/tsconfig.json", + "outDir": "./artifacts/dist_ts", + "resolvePaths": true, + "resolvePathsBaseDir": "./js" }, "inputs": [ "{projectRoot}/js/__internal/**/*.{ts,tsx}", @@ -321,10 +325,29 @@ "cache": true }, "build:npm:dual-mode": { - "executor": "nx:run-commands", + "executor": "devextreme-nx-infra-plugin:create-dual-mode-manifest", "options": { - "command": "cross-env BUILD_ESM_PACKAGE=true gulp esm-dual-mode-manifests", - "cwd": "{projectRoot}" + "esmDir": "./artifacts/transpiled-esm-npm/esm", + "cjsDir": "./artifacts/transpiled-esm-npm/cjs", + "outputDir": "./artifacts/transpiled-esm-npm", + "srcDir": "./js", + "excludePatterns": [ + "__internal/**/*", + "viz/docs/**/*", + "bundles/**/*" + ], + "generatedDtsFiles": [ + "events/click.d.ts", + "events/contextmenu.d.ts", + "events/dblclick.d.ts", + "events/drag.d.ts", + "events/hold.d.ts", + "events/hover.d.ts", + "events/pointer.d.ts", + "events/swipe.d.ts", + "events/transform.d.ts", + "integration/jquery.d.ts" + ] }, "inputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/esm/**/*", From 25ac18dba0d419013ac808da7de15fa45e5addf2 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 26 Jan 2026 13:57:44 +0200 Subject: [PATCH 4/8] chore(devextreme): remove transpile code migrated to nx executors --- packages/devextreme/build/gulp/transpile.js | 110 -------------------- 1 file changed, 110 deletions(-) diff --git a/packages/devextreme/build/gulp/transpile.js b/packages/devextreme/build/gulp/transpile.js index d4d9f8297aff..30af8ab122ba 100644 --- a/packages/devextreme/build/gulp/transpile.js +++ b/packages/devextreme/build/gulp/transpile.js @@ -1,28 +1,18 @@ 'use strict'; const babel = require('gulp-babel'); -const flatMap = require('gulp-flatmap'); -const fs = require('fs'); const gulp = require('gulp'); - -const normalize = require('normalize-path'); const notify = require('gulp-notify'); const path = require('path'); const plumber = require('gulp-plumber'); -const rename = require('gulp-rename'); -const replace = require('gulp-replace'); const watch = require('gulp-watch'); const removeDebug = require('./compression-pipes.js').removeDebug; const ctx = require('./context.js'); const testsConfig = require('../../testing/tests.babelrc.json'); const transpileConfig = require('./transpile-config'); - const createTsCompiler = require('./typescript/compiler'); -const { SideEffectFinder } = require('./side-effects-finder'); - -const sideEffectFinder = new SideEffectFinder(); const src = [ 'js/**/*.*', '!js/**/*.d.ts', @@ -30,30 +20,6 @@ const src = [ '!js/__internal/**/*.*', ]; -const esmTranspileSrc = src.concat([ - '!js/viz/docs/**/*', - '!**/*.json' -]); - -const srcTsPattern = 'js/__internal/**/*.{ts,tsx}'; -const srcTsIgnorePatterns = [ - '**/__tests__/**/*' -]; - -const srcDir = path.join(process.cwd(), './js'); -const generatedTs = [ - 'events/click.d.ts', - 'events/contextmenu.d.ts', - 'events/dblclick.d.ts', - 'events/drag.d.ts', - 'events/hold.d.ts', - 'events/hover.d.ts', - 'events/pointer.d.ts', - 'events/swipe.d.ts', - 'events/transform.d.ts', - 'integration/jquery.d.ts' -]; - const TS_OUTPUT_BASE_DIR = 'artifacts/dist_ts'; const TS_COMPILER_CONFIG = { baseAbsPath: path.resolve(__dirname, '../..'), @@ -70,82 +36,6 @@ const TS_COMPILER_CONFIG = { }, }; -const createModuleConfig = (name, dir, filePath, dist) => { - const isIndex = name === 'index.js'; - const relative = path.join('./', dir.replace(srcDir, ''), name); - const currentPath = isIndex ? path.join(relative, '../') : relative; - const esmFile = path.relative(currentPath, path.join('./esm', relative)); - const esmFilePath = path.join(dist, './esm', dir.replace(srcDir, ''), name); - const cjsFile = path.relative(currentPath, path.join('./cjs', relative)); - const hasRealDTS = fs.existsSync(filePath.replace(/\.js$/, '.d.ts')); - const hasGeneratedDTS = generatedTs.indexOf(relative.replace(/\.js$/, '.d.ts')) !== -1; - const hasDTS = hasRealDTS || hasGeneratedDTS; - const relativeEsmBase = normalize(esmFile).match(/^.*\/esm\//)[0]; - const sideEffectFiles = sideEffectFinder.getModuleSideEffectFiles(esmFilePath) - .map((importPath) => importPath.replace(/^.*\/esm\//, relativeEsmBase)); - - const result = { - sideEffects: sideEffectFiles.length ? sideEffectFiles : false, - main: normalize(cjsFile), - module: normalize(esmFile), - }; - - if(hasDTS) { - const typingFile = name.replace(/\.js$/, '.d.ts'); - - result['typings'] = `${isIndex ? './' : '../'}${typingFile}`; - } - - return JSON.stringify(result, null, 2); -}; - -const transpileTs = (compiler, src) => { - const task = () => compiler - .compileTs(src, srcTsIgnorePatterns) - .pipe(gulp.dest(TS_OUTPUT_BASE_DIR)); - - task.displayName = 'transpile TS'; - return task; -}; - -gulp.task('ts-compile-internal', (done) => { - createTsCompiler(TS_COMPILER_CONFIG).then((compiler) => { - transpileTs(compiler, srcTsPattern)() - .on('end', done) - .on('error', done); - }); -}); - -gulp.task('esm-dual-mode-manifests', () => { - const dist = ctx.TRANSPILED_PROD_ESM_PATH; - return gulp - .src(esmTranspileSrc) - .pipe(flatMap((stream, file) => { - const filePath = file.path; - const parsedPath = path.parse(filePath); - const fileName = parsedPath.base; - const fileDir = parsedPath.dir; - - // NOTE: flatmap thinks that the 'js/viz/vector_map.utils' folder is a file. - if(file.extname === '.utils') return stream; - - return stream - .pipe(replace(/[\s\S]*/, createModuleConfig(fileName, fileDir, filePath, dist))) - .pipe(rename(fPath => { - const isIndexFile = parsedPath.base === 'index.js'; - const shouldBePlacedInSeparateDir = !isIndexFile; - - if(shouldBePlacedInSeparateDir) { - fPath.dirname = path.join(fPath.dirname, fPath.basename); - } - - fPath.basename = 'package'; - fPath.extname = '.json'; - })); - })) - .pipe(gulp.dest(dist)); -}); - const watchJsTask = () => { const watchTask = watch(src) .on('ready', () => console.log('transpile JS is watching for changes...')) From 33b80f8515a046864a91b737dab778d19e400884 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Tue, 27 Jan 2026 16:59:35 +0200 Subject: [PATCH 5/8] docs: update copilot-instructions.md --- .github/copilot-instructions.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 19d6a2b65412..74fc770575f8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -71,7 +71,7 @@ pnpm install --frozen-lockfile devextreme-metadata/ # Metadata generation for wrappers devextreme-monorepo-tools/ # Internal tooling nx-infra-plugin/ # Custom Nx executors for build automation - workflows/ # Reusable CI/CD workflow configurations + workflows/ # Cross-package NX build orchestration (all:build-dev, all:build-testing) testcafe-models/ # TestCafe page object models /apps/ @@ -154,7 +154,7 @@ pnpm run clean **Build process includes:** 1. Localization generation (via `devextreme-nx-infra-plugin:localization` executor) 2. Component generation (Renovation architecture) -3. Transpilation (Babel) +3. Transpilation (via native NX executors: `babel-transform` for JS, `build-typescript` for TS) 4. Bundle creation (Webpack) - `bundle:debug` and `bundle:prod` targets 5. TypeScript declarations - `build:declarations` target 6. SCSS compilation (from devextreme-scss) @@ -186,13 +186,21 @@ The `packages/nx-infra-plugin` provides custom Nx executors for build automation | Executor | Description | |----------|-------------| -| `localization` | Generates localization message files and TypeScript CLDR data modules | -| `add-license-headers` | Adds license headers to source files | -| `copy-files` | Copies files with glob pattern support | -| `clean` | Cleans directories with exclude pattern support | -| `build-typescript` | Builds TypeScript projects | -| `generate-components` | Generates Angular/React/Vue wrapper components | -| `karma-multi-env` | Runs Karma tests across multiple Angular environments | +| `add-license-headers` | Adds DevExtreme license headers to compiled files with version information | +| `babel-transform` | Transforms JS/TS files using Babel with configurable presets, debug block removal, and extension renaming | +| `build-angular-library` | Builds Angular libraries using ng-packagr programmatically | +| `build-typescript` | Compiles TypeScript to CJS or ESM modules with configurable output format, tsconfig, and path alias resolution | +| `clean` | Removes directories and files with support for exclusion patterns | +| `concatenate-files` | Concatenates files with optional content extraction via regex, header/footer, and find/replace transforms | +| `copy-files` | Copies files and directories to specified destinations with glob pattern support | +| `create-dual-mode-manifest` | Generates package.json files for dual-mode (ESM + CJS) support with main, module, typings, and sideEffects | +| `generate-component-names` | Generates TypeScript file with component name constants for test automation | +| `generate-components` | Generates framework components (React/Vue/Angular) from DevExtreme metadata | +| `karma-multi-env` | Runs Karma tests across multiple Angular environments (client, server, hydration) | +| `localization` | Generates CLDR data and compiles localization message files from JSON to JavaScript | +| `pack-npm` | Creates npm packages using `pnpm pack` for distribution | +| `prepare-package-json` | Creates distribution-ready package.json with cleaned dependencies for npm publishing | +| `prepare-submodules` | Creates package.json entry points for submodule exports | **Example executor usage in project.json:** ```json From f11a63bc623e844eeed67dcdbe3bef3cab4f25c0 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 2 Feb 2026 09:34:43 +0200 Subject: [PATCH 6/8] fix(nx-infra-plugin): resolve babel-transform excludePatterns to absolute paths --- .../src/executors/babel-transform/executor.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/nx-infra-plugin/src/executors/babel-transform/executor.ts b/packages/nx-infra-plugin/src/executors/babel-transform/executor.ts index bba985f5a9f0..8809328cdb68 100644 --- a/packages/nx-infra-plugin/src/executors/babel-transform/executor.ts +++ b/packages/nx-infra-plugin/src/executors/babel-transform/executor.ts @@ -93,9 +93,15 @@ const runExecutor: PromiseExecutor = async (option const sourcePath = path.join(projectRoot, options.sourcePattern); const globPattern = isWindowsOS() ? normalizeGlobPathForWindows(sourcePath) : sourcePath; + const rawExcludePatterns = options.excludePatterns ?? []; + const excludePatterns = rawExcludePatterns.map((pattern) => { + const resolved = path.isAbsolute(pattern) ? pattern : path.join(projectRoot, pattern); + return isWindowsOS() ? normalizeGlobPathForWindows(resolved) : resolved; + }); + const sourceFiles = await glob(globPattern, { absolute: true, - ignore: options.excludePatterns || [], + ignore: excludePatterns, }); if (sourceFiles.length === 0) { From 818b4557967aaaee0efb785e3f5200ffa2ddf9dd Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 2 Feb 2026 09:34:48 +0200 Subject: [PATCH 7/8] fix(devextreme): include all config output dirs in target outputs for Nx caching --- packages/devextreme/project.json | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 0fe94944bf82..cb95f6d0af11 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -179,7 +179,10 @@ "!{projectRoot}/js/**/*.d.ts", "!{projectRoot}/js/__internal/**/*" ], - "outputs": ["{projectRoot}/artifacts/transpiled"], + "outputs": [ + "{projectRoot}/artifacts/transpiled", + "{projectRoot}/artifacts/transpiled-renovation-npm" + ], "cache": true }, "copy:json:transpiled": { @@ -272,7 +275,10 @@ } }, "inputs": ["{projectRoot}/artifacts/dist_ts/__internal/**/*.{js,jsx}"], - "outputs": ["{projectRoot}/artifacts/transpiled/__internal"], + "outputs": [ + "{projectRoot}/artifacts/transpiled/__internal", + "{projectRoot}/artifacts/transpiled-renovation-npm/__internal" + ], "cache": true }, "build:npm:esm:internal": { @@ -321,7 +327,11 @@ } }, "inputs": ["{projectRoot}/build/bundle-templates/**/*.js"], - "outputs": ["{projectRoot}/artifacts/transpiled/bundles"], + "outputs": [ + "{projectRoot}/artifacts/transpiled/bundles", + "{projectRoot}/artifacts/transpiled-renovation-npm/bundles", + "{projectRoot}/artifacts/transpiled-esm-npm/bundles" + ], "cache": true }, "build:npm:dual-mode": { From d5b6d046f3554cc30926f17f4b67e205967b169b Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Thu, 12 Feb 2026 11:36:33 +0200 Subject: [PATCH 8/8] chore: update lock file --- pnpm-lock.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa0f4f6a49c3..a6d76dfb1a16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2286,6 +2286,9 @@ importers: lodash: specifier: 4.17.23 version: 4.17.23 + minimatch: + specifier: 9.0.5 + version: 9.0.5 ng-packagr: specifier: '>=19.0.0' version: 19.2.2(@angular/compiler-cli@21.0.8(@angular/compiler@21.0.8)(typescript@4.9.5))(tslib@2.8.1)(typescript@4.9.5) @@ -2311,6 +2314,9 @@ importers: '@types/lodash': specifier: 4.17.23 version: 4.17.23 + '@types/minimatch': + specifier: 5.1.2 + version: 5.1.2 '@types/node': specifier: 18.19.130 version: 18.19.130 @@ -17404,6 +17410,10 @@ packages: '@swc/wasm': optional: true + tsc-alias@1.8.10: + resolution: {integrity: sha512-Ibv4KAWfFkFdKJxnWfVtdOmB0Zi1RJVxcbPGiCDsFpCQSsmpWyuzHG3rQyI5YkobWwxFPEyQfu1hdo4qLG2zPw==} + hasBin: true + tsc-alias@1.8.16: resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} engines: {node: '>=16.20.2'} @@ -40908,6 +40918,15 @@ snapshots: optionalDependencies: '@swc/core': 1.15.3 + tsc-alias@1.8.10: + dependencies: + chokidar: 3.6.0 + commander: 9.5.0 + globby: 11.1.0 + mylas: 2.1.13 + normalize-path: 3.0.0 + plimit-lit: 1.6.1 + tsc-alias@1.8.16: dependencies: chokidar: 3.6.0