From 99239ba96b11b846e584b118e2eb5ce330898e88 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Thu, 30 Apr 2026 12:47:38 +0300 Subject: [PATCH 01/27] feat(nx-infra-plugin): extend executors for npm-assembly migration --- .../add-license-headers/executor.e2e.spec.ts | 119 ++++++++++++++ .../executors/add-license-headers/executor.ts | 24 ++- .../executors/add-license-headers/schema.json | 6 + .../executors/add-license-headers/schema.ts | 1 + .../executors/copy-files/executor.e2e.spec.ts | 121 +++++++++++++++ .../src/executors/copy-files/executor.ts | 8 +- .../src/executors/copy-files/schema.json | 7 + .../src/executors/copy-files/schema.ts | 1 + .../prepare-package-json/executor.e2e.spec.ts | 146 ++++++++++++++++++ .../prepare-package-json/executor.ts | 51 +++++- .../prepare-package-json/schema.json | 39 +++++ .../executors/prepare-package-json/schema.ts | 11 ++ 12 files changed, 522 insertions(+), 12 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts index 1800cae38b34..a28319b581e6 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts @@ -9,6 +9,24 @@ describe('AddLicenseHeadersExecutor E2E', () => { let tempDir: string; let context = createMockContext(); + async function setupLicenseHeaderTemplate(): Promise { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const buildDir = path.join(projectDir, 'build', 'gulp'); + fs.mkdirSync(buildDir, { recursive: true }); + await writeFileText( + path.join(buildDir, 'license-header.txt'), + `/*<%= commentType %> +* DevExtreme (<%= file.relative %>) +* Version: <%= version %> +* Build date: <%= date %> +* +* Copyright (c) 2012 - <%= year %> Developer Express Inc. ALL RIGHTS RESERVED +* Read about DevExtreme licensing here: <%= eula %> +*/ +`, + ); + } + beforeEach(async () => { tempDir = createTempDir('nx-license-e2e-'); context = createMockContext({ root: tempDir }); @@ -322,4 +340,105 @@ export const value = 42; expect(contentWithoutHeader).toBe(originalContent); }); + + it('should produce /** banner when commentType is *', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const npmDir = path.join(projectDir, 'npm'); + await setupLicenseHeaderTemplate(); + + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + licenseTemplateFile: './build/gulp/license-header.txt', + eulaUrl: 'https://js.devexpress.com/Licensing/', + includePatterns: ['**/*.js'], + commentType: '*', + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const content = await readFileText(path.join(npmDir, 'index.js')); + expect(content).toMatch(/^\/\*\*/); + expect(content).not.toMatch(/^\/\*!/); + }); + + it('should skip files already stamped with /** when commentType is *', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const npmDir = path.join(projectDir, 'npm'); + await setupLicenseHeaderTemplate(); + + await writeFileText( + path.join(npmDir, 'pre-stamped.js'), + `/**\n * Already stamped\n */\nexport const x = 1;\n`, + ); + + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + licenseTemplateFile: './build/gulp/license-header.txt', + eulaUrl: 'https://js.devexpress.com/Licensing/', + includePatterns: ['**/*.js'], + commentType: '*', + }; + + await executor(options, context); + + const contentAfterFirst = await readFileText(path.join(npmDir, 'pre-stamped.js')); + + await executor(options, context); + + const contentAfterSecond = await readFileText(path.join(npmDir, 'pre-stamped.js')); + + expect(contentAfterFirst).toBe(contentAfterSecond); + expect((contentAfterFirst.match(/\/\*\*/g) || []).length).toBe(1); + }); + + it('should default to ! when commentType is not specified', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const npmDir = path.join(projectDir, 'npm'); + await setupLicenseHeaderTemplate(); + + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + licenseTemplateFile: './build/gulp/license-header.txt', + eulaUrl: 'https://js.devexpress.com/Licensing/', + includePatterns: ['**/*.js'], + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const content = await readFileText(path.join(npmDir, 'index.js')); + expect(content).toMatch(/^\/\*!/); + expect(content).not.toMatch(/^\/\*\*/); + }); + + it('should use commentType in default banner when no licenseTemplateFile is provided', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const npmDir = path.join(projectDir, 'npm'); + + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + commentType: '*', + includePatterns: ['**/*.js'], + }; + + const result1 = await executor(options, context); + expect(result1.success).toBe(true); + + const contentAfterFirst = await readFileText(path.join(npmDir, 'index.js')); + expect(contentAfterFirst).toMatch(/^\/\*\*/); + expect(contentAfterFirst).not.toMatch(/^\/\*!/); + + const result2 = await executor(options, context); + expect(result2.success).toBe(true); + + const contentAfterSecond = await readFileText(path.join(npmDir, 'index.js')); + expect(contentAfterSecond).toBe(contentAfterFirst); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts index 342bc40a22d5..8d3538dea81c 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts @@ -38,7 +38,7 @@ const DEFAULTS = { } as const; const COMMENT = { - MARKER: '/*!', + OPEN: '/*', END: ' */', PREFIX: ' *', } as const; @@ -81,9 +81,10 @@ function extractGitHubUrl( return rawUrl.replace(/^git\+/, '').replace(/\.git$/, ''); } -function buildDefaultBannerTemplate(): string { +function buildDefaultBannerTemplate(commentType: string): string { + const marker = `${COMMENT.OPEN}${commentType}`; return [ - COMMENT.MARKER, + marker, BANNER.PKG_NAME, BANNER.VERSION, BANNER.BUILD_DATE, @@ -148,6 +149,7 @@ interface ProcessFileOptions { useCustomTemplate: boolean; separatorBetweenBannerAndContent: string; prependAfterLicense: string; + commentType: string; } async function processFile(options: ProcessFileOptions): Promise { @@ -160,11 +162,12 @@ async function processFile(options: ProcessFileOptions): Promise { useCustomTemplate, separatorBetweenBannerAndContent, prependAfterLicense, + commentType, } = options; const content = await readFileText(file); - if (content.startsWith(COMMENT.MARKER)) { + if (content.startsWith(COMMENT.OPEN + commentType)) { return; } @@ -172,7 +175,7 @@ async function processFile(options: ProcessFileOptions): Promise { const fileData: FileTemplateData = { ...baseData, file: { relative: relativePath }, - commentType: '!', + commentType, }; const banner = useCustomTemplate @@ -195,9 +198,10 @@ interface LoadTemplateError { async function loadBannerTemplate( absoluteProjectRoot: string, licenseTemplateFile: string | undefined, + commentType: string, ): Promise { if (!licenseTemplateFile) { - return { success: true, template: buildDefaultBannerTemplate() }; + return { success: true, template: buildDefaultBannerTemplate(commentType) }; } const templatePath = path.join(absoluteProjectRoot, licenseTemplateFile); @@ -224,6 +228,7 @@ const runExecutor: PromiseExecutor = async (opt options.separatorBetweenBannerAndContent ?? CHARS.NEWLINE; const prependAfterLicense = options.prependAfterLicense ?? ''; const useCustomTemplate = !!options.licenseTemplateFile; + const commentType = options.commentType ?? '!'; let pkg: PackageJson; try { @@ -235,7 +240,11 @@ const runExecutor: PromiseExecutor = async (opt const githubUrl = useCustomTemplate ? '' : extractGitHubUrl(pkg.repository, packageJsonPath); - const templateResult = await loadBannerTemplate(absoluteProjectRoot, options.licenseTemplateFile); + const templateResult = await loadBannerTemplate( + absoluteProjectRoot, + options.licenseTemplateFile, + commentType, + ); if (!templateResult.success) { return { success: false }; } @@ -273,6 +282,7 @@ const runExecutor: PromiseExecutor = async (opt useCustomTemplate, separatorBetweenBannerAndContent, prependAfterLicense, + commentType, }), ), ); diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json index 3513654e0605..8fd44ef452a0 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json @@ -50,6 +50,12 @@ "version": { "type": "string", "description": "Version to use in template (defaults to pkg.version)" + }, + "commentType": { + "type": "string", + "description": "Comment type marker placed after /* in the license banner opening", + "enum": ["!", "*"], + "default": "!" } }, "required": [] diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts index 706bc02cfb85..16e388b044b8 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts @@ -8,4 +8,5 @@ export interface AddLicenseHeadersExecutorSchema { eulaUrl?: string; prependAfterLicense?: string; version?: string; + commentType?: '!' | '*'; } diff --git a/packages/nx-infra-plugin/src/executors/copy-files/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/copy-files/executor.e2e.spec.ts index ac36e37a8999..e047d89bfb4d 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/copy-files/executor.e2e.spec.ts @@ -9,6 +9,20 @@ describe('CopyFilesExecutor E2E', () => { let tempDir: string; let context = createMockContext(); + async function setupExcludePatternsFixture(): Promise { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const srcDir = path.join(projectDir, 'src'); + const testDir = path.join(srcDir, 'test'); + + fs.mkdirSync(testDir, { recursive: true }); + + await writeFileText(path.join(srcDir, 'app.js'), 'export const app = true;'); + await writeFileText(path.join(srcDir, 'utils.js'), 'export const utils = true;'); + await writeFileText(path.join(srcDir, 'helper.spec.js'), 'test("helper", () => {});'); + await writeFileText(path.join(testDir, 'app.spec.js'), 'test("app", () => {});'); + await writeFileText(path.join(testDir, 'utils.spec.js'), 'test("utils", () => {});'); + } + beforeEach(async () => { tempDir = createTempDir('nx-copy-e2e-'); context = createMockContext({ root: tempDir }); @@ -130,4 +144,111 @@ describe('CopyFilesExecutor E2E', () => { expect(fs.existsSync(path.join(distDir, 'other.js'))).toBe(false); }); }); + + it('should exclude files matching excludePatterns from glob copy', async () => { + await setupExcludePatternsFixture(); + + const options: CopyFilesExecutorSchema = { + files: [ + { + from: './src/**/*.js', + to: './dist', + excludePatterns: ['**/test/**', '**/*.spec.js'], + }, + ], + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distDir = path.join(projectDir, 'dist'); + + expect(fs.existsSync(path.join(distDir, 'app.js'))).toBe(true); + expect(fs.existsSync(path.join(distDir, 'utils.js'))).toBe(true); + expect(fs.existsSync(path.join(distDir, 'helper.spec.js'))).toBe(false); + expect(fs.existsSync(path.join(distDir, 'app.spec.js'))).toBe(false); + expect(fs.existsSync(path.join(distDir, 'utils.spec.js'))).toBe(false); + }); + + it('should copy all files when excludePatterns is omitted', async () => { + await setupExcludePatternsFixture(); + + const options: CopyFilesExecutorSchema = { + files: [{ from: './src/**/*.js', to: './dist2' }], + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distDir = path.join(projectDir, 'dist2'); + + expect(fs.existsSync(path.join(distDir, 'app.js'))).toBe(true); + expect(fs.existsSync(path.join(distDir, 'helper.spec.js'))).toBe(true); + expect(fs.existsSync(path.join(distDir, 'utils.spec.js'))).toBe(true); + }); + + it('should copy all files when excludePatterns is an empty array', async () => { + await setupExcludePatternsFixture(); + + const options: CopyFilesExecutorSchema = { + files: [{ from: './src/**/*.js', to: './dist3', excludePatterns: [] }], + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distDir = path.join(projectDir, 'dist3'); + + expect(fs.existsSync(path.join(distDir, 'app.js'))).toBe(true); + expect(fs.existsSync(path.join(distDir, 'helper.spec.js'))).toBe(true); + expect(fs.existsSync(path.join(distDir, 'utils.spec.js'))).toBe(true); + }); + + it('should apply different excludePatterns independently per file entry', async () => { + await setupExcludePatternsFixture(); + + const options: CopyFilesExecutorSchema = { + files: [ + { from: './src/**/*.js', to: './out-a', excludePatterns: ['**/*.spec.js'] }, + { from: './src/**/*.js', to: './out-b', excludePatterns: ['**/test/**'] }, + ], + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + + const outA = path.join(projectDir, 'out-a'); + expect(fs.existsSync(path.join(outA, 'app.js'))).toBe(true); + expect(fs.existsSync(path.join(outA, 'helper.spec.js'))).toBe(false); + expect(fs.existsSync(path.join(outA, 'utils.spec.js'))).toBe(false); + + const outB = path.join(projectDir, 'out-b'); + expect(fs.existsSync(path.join(outB, 'app.js'))).toBe(true); + expect(fs.existsSync(path.join(outB, 'helper.spec.js'))).toBe(true); + expect(fs.existsSync(path.join(outB, 'utils.spec.js'))).toBe(false); + }); + + it('should not error when excludePatterns is specified alongside a direct file path', async () => { + await setupExcludePatternsFixture(); + + const options: CopyFilesExecutorSchema = { + files: [{ from: './README.md', to: './dist-direct/README.md', excludePatterns: ['**/*.md'] }], + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + expect(fs.existsSync(path.join(projectDir, 'dist-direct', 'README.md'))).toBe(true); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/copy-files/executor.ts b/packages/nx-infra-plugin/src/executors/copy-files/executor.ts index 49e363919cd8..40744987027c 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/executor.ts +++ b/packages/nx-infra-plugin/src/executors/copy-files/executor.ts @@ -18,9 +18,11 @@ const ERROR_MESSAGES = { async function copyGlobPatternFiles( sourcePath: string, destPath: string, + excludePatterns: string[] = [], ): Promise<{ success: boolean }> { const globPattern = isWindowsOS() ? normalizeGlobPathForWindows(sourcePath) : sourcePath; - const files = await glob(globPattern, { nodir: true }); + const ignore = isWindowsOS() ? excludePatterns.map(normalizeGlobPathForWindows) : excludePatterns; + const files = await glob(globPattern, { nodir: true, ignore }); if (files.length === 0) { logger.error(ERROR_MESSAGES.NO_FILES_MATCH_PATTERN(sourcePath)); @@ -67,12 +69,12 @@ const runExecutor: PromiseExecutor = async (options, co } try { - for (const { from, to } of options.files) { + for (const { from, to, excludePatterns } of options.files) { const sourcePath = path.resolve(projectRoot, from); const destPath = path.resolve(projectRoot, to); const result = containsGlobPattern(from) - ? await copyGlobPatternFiles(sourcePath, destPath) + ? await copyGlobPatternFiles(sourcePath, destPath, excludePatterns) : await copyDirectPath(sourcePath, destPath); if (!result.success) { diff --git a/packages/nx-infra-plugin/src/executors/copy-files/schema.json b/packages/nx-infra-plugin/src/executors/copy-files/schema.json index 08f65d656642..b4f419373bce 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/schema.json +++ b/packages/nx-infra-plugin/src/executors/copy-files/schema.json @@ -14,6 +14,13 @@ "to": { "type": "string", "description": "Destination path relative to project root. For glob patterns, this should be a directory." + }, + "excludePatterns": { + "type": "array", + "description": "Glob patterns for files to exclude when copying via a glob pattern in from.", + "items": { + "type": "string" + } } }, "required": ["from", "to"] diff --git a/packages/nx-infra-plugin/src/executors/copy-files/schema.ts b/packages/nx-infra-plugin/src/executors/copy-files/schema.ts index 9d081978ac5a..af83c2648650 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/schema.ts +++ b/packages/nx-infra-plugin/src/executors/copy-files/schema.ts @@ -2,5 +2,6 @@ export interface CopyFilesExecutorSchema { files: Array<{ from: string; to: string; + excludePatterns?: string[]; }>; } diff --git a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts index d11d2c288583..158a764fdd9f 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts @@ -100,4 +100,150 @@ describe('PreparePackageJsonExecutor E2E', () => { expect(distPackage.author).toBe('Test Author'); }); }); + + it('should override name and version with setName and setVersion', async () => { + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + setName: 'devextreme', + setVersion: '1.2.3', + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distPkg = JSON.parse(await readFileText(path.join(projectDir, 'npm', 'package.json'))); + + expect(distPkg.name).toBe('devextreme'); + expect(distPkg.version).toBe('1.2.3'); + }); + + it('should remove all specified fields via removeFields', async () => { + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + removeFields: ['devDependencies', 'publishConfig', 'scripts'], + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distPkg = JSON.parse(await readFileText(path.join(projectDir, 'npm', 'package.json'))); + + expect(distPkg.devDependencies).toBeUndefined(); + expect(distPkg.publishConfig).toBeUndefined(); + expect(distPkg.scripts).toBeUndefined(); + }); + + it('should read version from versionFrom file and apply it to the output', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + + await writeJson(path.join(projectDir, 'version-source.json'), { + name: 'workspace-root', + version: '9.8.7', + }); + + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + versionFrom: './version-source.json', + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const distPkg = JSON.parse(await readFileText(path.join(projectDir, 'npm', 'package.json'))); + + expect(distPkg.version).toBe('9.8.7'); + }); + + it('should rename devextreme to devextreme-internal after setName via renameInternalPattern', async () => { + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + setName: 'devextreme', + renameInternalPattern: { find: '^devextreme(-.*)?$', replace: 'devextreme$1-internal' }, + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distPkg = JSON.parse(await readFileText(path.join(projectDir, 'npm', 'package.json'))); + + expect(distPkg.name).toBe('devextreme-internal'); + }); + + it('should rename devextreme-foo to devextreme-foo-internal via renameInternalPattern', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + + await writeJson(path.join(projectDir, 'package.json'), { + name: 'devextreme-foo', + version: '1.0.0', + publishConfig: { access: 'public' }, + }); + + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + renameInternalPattern: { find: '^devextreme(-.*)?$', replace: 'devextreme$1-internal' }, + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const distPkg = JSON.parse(await readFileText(path.join(projectDir, 'npm', 'package.json'))); + + expect(distPkg.name).toBe('devextreme-foo-internal'); + }); + + it('should remove only publishConfig by default when no new options are specified', async () => { + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + }; + + await executor(options, context); + + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distPkg = JSON.parse(await readFileText(path.join(projectDir, 'npm', 'package.json'))); + + expect(distPkg.publishConfig).toBeUndefined(); + expect(distPkg.devDependencies).toBeDefined(); + expect(distPkg.scripts).toBeDefined(); + }); + + it('should remove nothing when removeFields is an empty array', async () => { + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + removeFields: [], + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distPkg = JSON.parse(await readFileText(path.join(projectDir, 'npm', 'package.json'))); + + expect(distPkg.publishConfig).toBeDefined(); + }); + + it('should fail when versionFrom file has no version field', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + + await writeJson(path.join(projectDir, 'no-version.json'), { + name: 'workspace-root', + }); + + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + versionFrom: './no-version.json', + }; + + const result = await executor(options, context); + + expect(result.success).toBe(false); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts index afd664eac3b9..8659376bbbb8 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts +++ b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts @@ -15,6 +15,41 @@ const JSON_INDENT = 2; const ERROR_PREPARE_PACKAGE_JSON = 'Failed to prepare package.json'; +interface PackageJsonTransformations { + setName?: string; + setVersion?: string; + renameInternalPattern?: { find: string; replace: string }; + removeFields?: string[]; +} + +function applyPackageJsonTransformations( + pkg: Record, + transformations: PackageJsonTransformations, + versionFromValue?: unknown, +): Record { + if (transformations.setName !== undefined) { + pkg['name'] = transformations.setName; + } + + if (transformations.setVersion !== undefined) { + pkg['version'] = transformations.setVersion; + } else if (versionFromValue !== undefined) { + pkg['version'] = versionFromValue; + } + + if (transformations.renameInternalPattern !== undefined) { + const { find, replace } = transformations.renameInternalPattern; + pkg['name'] = String.prototype.replace.call(String(pkg['name']), new RegExp(find), replace); + } + + const fieldsToRemove = transformations.removeFields ?? [PUBLISH_CONFIG_FIELD]; + for (const field of fieldsToRemove) { + delete pkg[field]; + } + + return pkg; +} + const runExecutor: PromiseExecutor = async (options, context) => { const absoluteProjectRoot = resolveProjectPath(context); const sourcePackageJson = path.join( @@ -27,9 +62,21 @@ const runExecutor: PromiseExecutor = async (options, c await ensureDir(distDirectory); const pkg = await readJson>(sourcePackageJson); - delete pkg[PUBLISH_CONFIG_FIELD]; - const distPackageJson = path.join(distDirectory, PACKAGE_JSON_FILE); + let versionFromValue: unknown; + if (options.setVersion === undefined && options.versionFrom !== undefined) { + const versionSourcePath = path.join(absoluteProjectRoot, options.versionFrom); + const versionSource = await readJson>(versionSourcePath); + if (versionSource['version'] === undefined) { + throw new Error(`No 'version' field in ${versionSourcePath}`); + } + versionFromValue = versionSource['version']; + } + + applyPackageJsonTransformations(pkg, options, versionFromValue); + + const outputFileName = options.outputFileName ?? PACKAGE_JSON_FILE; + const distPackageJson = path.join(distDirectory, outputFileName); await writeJson(distPackageJson, pkg, JSON_INDENT); logger.verbose(`Created ${distPackageJson}`); diff --git a/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.json b/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.json index d555d19c5d7f..308f88720d90 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.json +++ b/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.json @@ -10,6 +10,45 @@ "type": "string", "description": "Distribution directory", "default": "./npm" + }, + "outputFileName": { + "type": "string", + "description": "Output filename inside distDirectory", + "default": "package.json" + }, + "setName": { + "type": "string", + "description": "Override the name field in the output package.json" + }, + "setVersion": { + "type": "string", + "description": "Override the version field in the output package.json with a literal value" + }, + "versionFrom": { + "type": "string", + "description": "Path relative to project root of a package.json-shaped file whose version field is used" + }, + "removeFields": { + "type": "array", + "description": "Fields to delete from the output package.json (default: [publishConfig])", + "items": { + "type": "string" + } + }, + "renameInternalPattern": { + "type": "object", + "description": "Regex pattern applied to pkg.name to produce an internal package name", + "properties": { + "find": { + "type": "string", + "description": "Regex pattern string" + }, + "replace": { + "type": "string", + "description": "Replacement string (supports $1, $2, etc.)" + } + }, + "required": ["find", "replace"] } }, "required": [] diff --git a/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.ts b/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.ts index 8e294d1d6300..83c81b909f27 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.ts +++ b/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.ts @@ -1,4 +1,15 @@ +export interface RenameInternalPattern { + find: string; + replace: string; +} + export interface NpmPackageExecutorSchema { sourcePackageJson?: string; distDirectory?: string; + outputFileName?: string; + setName?: string; + setVersion?: string; + versionFrom?: string; + removeFields?: string[]; + renameInternalPattern?: RenameInternalPattern; } From 56da2b688f76aa1726ae268b2a2a73a59302f9d3 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Thu, 30 Apr 2026 15:48:33 +0300 Subject: [PATCH 02/27] feat(nx-infra-plugin): dts-modules and dts-bundle executors for npm-assembly --- .../build/npm-templates/bundles/dx.all.js | 1 + .../build/npm-templates/events/click.d.ts | 1 + .../npm-templates/events/contextmenu.d.ts | 1 + .../build/npm-templates/events/dblclick.d.ts | 1 + .../build/npm-templates/events/drag.d.ts | 1 + .../build/npm-templates/events/hold.d.ts | 1 + .../build/npm-templates/events/hover.d.ts | 1 + .../build/npm-templates/events/pointer.d.ts | 1 + .../build/npm-templates/events/swipe.d.ts | 1 + .../build/npm-templates/events/transform.d.ts | 1 + .../npm-templates/integration/jquery.d.ts | 1 + packages/devextreme/project.json | 38 +++ packages/nx-infra-plugin/executors.json | 10 + .../executors/add-license-headers/executor.ts | 233 ++---------------- .../src/executors/compress/executor.ts | 21 +- .../executors/concatenate-files/executor.ts | 106 +------- .../executors/dts-bundle/executor.e2e.spec.ts | 112 +++++++++ .../src/executors/dts-bundle/executor.ts | 68 +++++ .../src/executors/dts-bundle/schema.json | 29 +++ .../src/executors/dts-bundle/schema.ts | 7 + .../dts-modules/executor.e2e.spec.ts | 154 ++++++++++++ .../src/executors/dts-modules/executor.ts | 86 +++++++ .../src/executors/dts-modules/schema.json | 28 +++ .../src/executors/dts-modules/schema.ts | 7 + .../src/utils/concat-content.ts | 79 ++++++ .../src/utils/copy-directory.ts | 32 +++ .../nx-infra-plugin/src/utils/debug-strip.ts | 5 + .../src/utils/license-banner.e2e.spec.ts | 45 ++++ .../src/utils/license-banner.ts | 125 ++++++++++ 29 files changed, 875 insertions(+), 321 deletions(-) create mode 100644 packages/devextreme/build/npm-templates/bundles/dx.all.js create mode 100644 packages/devextreme/build/npm-templates/events/click.d.ts create mode 100644 packages/devextreme/build/npm-templates/events/contextmenu.d.ts create mode 100644 packages/devextreme/build/npm-templates/events/dblclick.d.ts create mode 100644 packages/devextreme/build/npm-templates/events/drag.d.ts create mode 100644 packages/devextreme/build/npm-templates/events/hold.d.ts create mode 100644 packages/devextreme/build/npm-templates/events/hover.d.ts create mode 100644 packages/devextreme/build/npm-templates/events/pointer.d.ts create mode 100644 packages/devextreme/build/npm-templates/events/swipe.d.ts create mode 100644 packages/devextreme/build/npm-templates/events/transform.d.ts create mode 100644 packages/devextreme/build/npm-templates/integration/jquery.d.ts create mode 100644 packages/nx-infra-plugin/src/executors/dts-bundle/executor.e2e.spec.ts create mode 100644 packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts create mode 100644 packages/nx-infra-plugin/src/executors/dts-bundle/schema.json create mode 100644 packages/nx-infra-plugin/src/executors/dts-bundle/schema.ts create mode 100644 packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts create mode 100644 packages/nx-infra-plugin/src/executors/dts-modules/executor.ts create mode 100644 packages/nx-infra-plugin/src/executors/dts-modules/schema.json create mode 100644 packages/nx-infra-plugin/src/executors/dts-modules/schema.ts create mode 100644 packages/nx-infra-plugin/src/utils/concat-content.ts create mode 100644 packages/nx-infra-plugin/src/utils/copy-directory.ts create mode 100644 packages/nx-infra-plugin/src/utils/debug-strip.ts create mode 100644 packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts create mode 100644 packages/nx-infra-plugin/src/utils/license-banner.ts diff --git a/packages/devextreme/build/npm-templates/bundles/dx.all.js b/packages/devextreme/build/npm-templates/bundles/dx.all.js new file mode 100644 index 000000000000..138550244ebd --- /dev/null +++ b/packages/devextreme/build/npm-templates/bundles/dx.all.js @@ -0,0 +1 @@ +// This file is required to compile devextreme-angular \ No newline at end of file diff --git a/packages/devextreme/build/npm-templates/events/click.d.ts b/packages/devextreme/build/npm-templates/events/click.d.ts new file mode 100644 index 000000000000..1b15a1e6b498 --- /dev/null +++ b/packages/devextreme/build/npm-templates/events/click.d.ts @@ -0,0 +1 @@ +import DevExpress from '../bundles/dx.all'; \ No newline at end of file diff --git a/packages/devextreme/build/npm-templates/events/contextmenu.d.ts b/packages/devextreme/build/npm-templates/events/contextmenu.d.ts new file mode 100644 index 000000000000..1b15a1e6b498 --- /dev/null +++ b/packages/devextreme/build/npm-templates/events/contextmenu.d.ts @@ -0,0 +1 @@ +import DevExpress from '../bundles/dx.all'; \ No newline at end of file diff --git a/packages/devextreme/build/npm-templates/events/dblclick.d.ts b/packages/devextreme/build/npm-templates/events/dblclick.d.ts new file mode 100644 index 000000000000..1b15a1e6b498 --- /dev/null +++ b/packages/devextreme/build/npm-templates/events/dblclick.d.ts @@ -0,0 +1 @@ +import DevExpress from '../bundles/dx.all'; \ No newline at end of file diff --git a/packages/devextreme/build/npm-templates/events/drag.d.ts b/packages/devextreme/build/npm-templates/events/drag.d.ts new file mode 100644 index 000000000000..1b15a1e6b498 --- /dev/null +++ b/packages/devextreme/build/npm-templates/events/drag.d.ts @@ -0,0 +1 @@ +import DevExpress from '../bundles/dx.all'; \ No newline at end of file diff --git a/packages/devextreme/build/npm-templates/events/hold.d.ts b/packages/devextreme/build/npm-templates/events/hold.d.ts new file mode 100644 index 000000000000..1b15a1e6b498 --- /dev/null +++ b/packages/devextreme/build/npm-templates/events/hold.d.ts @@ -0,0 +1 @@ +import DevExpress from '../bundles/dx.all'; \ No newline at end of file diff --git a/packages/devextreme/build/npm-templates/events/hover.d.ts b/packages/devextreme/build/npm-templates/events/hover.d.ts new file mode 100644 index 000000000000..1b15a1e6b498 --- /dev/null +++ b/packages/devextreme/build/npm-templates/events/hover.d.ts @@ -0,0 +1 @@ +import DevExpress from '../bundles/dx.all'; \ No newline at end of file diff --git a/packages/devextreme/build/npm-templates/events/pointer.d.ts b/packages/devextreme/build/npm-templates/events/pointer.d.ts new file mode 100644 index 000000000000..1b15a1e6b498 --- /dev/null +++ b/packages/devextreme/build/npm-templates/events/pointer.d.ts @@ -0,0 +1 @@ +import DevExpress from '../bundles/dx.all'; \ No newline at end of file diff --git a/packages/devextreme/build/npm-templates/events/swipe.d.ts b/packages/devextreme/build/npm-templates/events/swipe.d.ts new file mode 100644 index 000000000000..1b15a1e6b498 --- /dev/null +++ b/packages/devextreme/build/npm-templates/events/swipe.d.ts @@ -0,0 +1 @@ +import DevExpress from '../bundles/dx.all'; \ No newline at end of file diff --git a/packages/devextreme/build/npm-templates/events/transform.d.ts b/packages/devextreme/build/npm-templates/events/transform.d.ts new file mode 100644 index 000000000000..1b15a1e6b498 --- /dev/null +++ b/packages/devextreme/build/npm-templates/events/transform.d.ts @@ -0,0 +1 @@ +import DevExpress from '../bundles/dx.all'; \ No newline at end of file diff --git a/packages/devextreme/build/npm-templates/integration/jquery.d.ts b/packages/devextreme/build/npm-templates/integration/jquery.d.ts new file mode 100644 index 000000000000..cebdb0197337 --- /dev/null +++ b/packages/devextreme/build/npm-templates/integration/jquery.d.ts @@ -0,0 +1 @@ +import 'jquery'; \ No newline at end of file diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index fe3ac535430c..0995e47bd294 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -930,6 +930,44 @@ "{projectRoot}/artifacts/npm/devextreme/**/*.d.ts" ] }, + "build:npm:dts-modules": { + "executor": "devextreme-nx-infra-plugin:dts-modules", + "options": { + "sourceDir": "./js", + "outputDir": "./artifacts/npm/devextreme", + "legacyTemplatesDir": "./build/npm-templates", + "licenseTemplateFile": "./build/gulp/license-header.txt", + "eulaUrl": "https://js.devexpress.com/Licensing/" + }, + "inputs": [ + "{projectRoot}/js/**/*.d.ts", + "{projectRoot}/build/npm-templates/**/*", + "{projectRoot}/build/gulp/license-header.txt" + ], + "outputs": [ + "{projectRoot}/artifacts/npm/devextreme/**/*.d.ts", + "{projectRoot}/artifacts/npm/devextreme/bundles/dx.all.js" + ] + }, + "build:npm:dts-bundle": { + "executor": "devextreme-nx-infra-plugin:dts-bundle", + "options": { + "bundleSources": ["./ts/dx.all.d.ts", "./ts/aliases.d.ts"], + "artifactPath": "./artifacts/ts/dx.all.d.ts", + "packagePath": "./artifacts/npm/devextreme/bundles/dx.all.d.ts", + "licenseTemplateFile": "./build/gulp/license-header.txt", + "eulaUrl": "https://js.devexpress.com/Licensing/" + }, + "inputs": [ + "{projectRoot}/ts/dx.all.d.ts", + "{projectRoot}/ts/aliases.d.ts", + "{projectRoot}/build/gulp/license-header.txt" + ], + "outputs": [ + "{projectRoot}/artifacts/ts/dx.all.d.ts", + "{projectRoot}/artifacts/npm/devextreme/bundles/dx.all.d.ts" + ] + }, "compress:npm-sources": { "executor": "devextreme-nx-infra-plugin:compress", "options": { diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index e09768781710..e3136ab70dbf 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -89,6 +89,16 @@ "implementation": "./src/executors/compress/executor", "schema": "./src/executors/compress/schema.json", "description": "Compress JavaScript files" + }, + "dts-modules": { + "implementation": "./src/executors/dts-modules/executor", + "schema": "./src/executors/dts-modules/schema.json", + "description": "Assemble TypeScript declaration modules: copy, stamp with license, and strip debug blocks" + }, + "dts-bundle": { + "implementation": "./src/executors/dts-bundle/executor", + "schema": "./src/executors/dts-bundle/schema.json", + "description": "Assemble TypeScript declaration bundle files from source" } } } diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts index 8d3538dea81c..dccff8c50102 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts @@ -1,12 +1,12 @@ import { PromiseExecutor, logger } from '@nx/devkit'; import * as path from 'path'; import { glob } from 'glob'; -import _ from 'lodash'; import { AddLicenseHeadersExecutorSchema } from './schema'; import { resolveProjectPath, normalizeGlobPathForWindows } from '../../utils/path-resolver'; import { isWindowsOS } from '../../utils/common'; import { logError } from '../../utils/error-handler'; -import { readJson, readFileText, writeFileText } from '../../utils/file-operations'; +import { readJson } from '../../utils/file-operations'; +import { buildLicenseBannerRenderer, applyLicenseBannerToFile } from '../../utils/license-banner'; interface PackageJson { name: string; @@ -14,22 +14,6 @@ interface PackageJson { repository?: string | { url?: string }; } -interface BaseTemplateData { - pkg: PackageJson; - date: string; - year: number; - githubUrl: string; - eula: string; - version: string; -} - -interface FileTemplateData extends BaseTemplateData { - file: { - relative: string; - }; - commentType: string; -} - const DEFAULTS = { TARGET_DIR: './npm', PACKAGE_JSON: './package.json', @@ -37,86 +21,6 @@ const DEFAULTS = { EXCLUDE_PATTERNS: ['**/*.json', '**/*.map'], } as const; -const COMMENT = { - OPEN: '/*', - END: ' */', - PREFIX: ' *', -} as const; - -const CHARS = { - NEWLINE: '\n', - EMPTY_LINE: '', -} as const; - -const BANNER = { - PKG_NAME: `${COMMENT.PREFIX} <%= pkg.name %>`, - VERSION: `${COMMENT.PREFIX} Version: <%= pkg.version %>`, - BUILD_DATE: `${COMMENT.PREFIX} Build date: <%= date %>`, - COPYRIGHT: `${COMMENT.PREFIX} Copyright (c) 2012 - <%= year %> Developer Express Inc. ALL RIGHTS RESERVED`, - LICENSE_LINE1: `${COMMENT.PREFIX} This software may be modified and distributed under the terms`, - LICENSE_LINE2: `${COMMENT.PREFIX} of the MIT license. See the LICENSE file in the root of the project for details.`, - GITHUB: `${COMMENT.PREFIX} <%= githubUrl %>`, -} as const; - -const TEMPLATE_REGEX = /<%=\s*(\w+(?:\.\w+)*)\s*%>/g; - -function extractGitHubUrl( - repository: string | { url?: string } | undefined, - packageJsonPath: string, -): string { - if (!repository) { - throw new Error( - `Missing 'repository' field in ${packageJsonPath}. License headers require a repository URL.`, - ); - } - - const rawUrl = typeof repository === 'string' ? repository : repository.url; - - if (!rawUrl) { - throw new Error( - `Invalid 'repository' format in ${packageJsonPath}. Expected string or object with 'url' property.`, - ); - } - - return rawUrl.replace(/^git\+/, '').replace(/\.git$/, ''); -} - -function buildDefaultBannerTemplate(commentType: string): string { - const marker = `${COMMENT.OPEN}${commentType}`; - return [ - marker, - BANNER.PKG_NAME, - BANNER.VERSION, - BANNER.BUILD_DATE, - COMMENT.PREFIX, - BANNER.COPYRIGHT, - COMMENT.PREFIX, - BANNER.LICENSE_LINE1, - BANNER.LICENSE_LINE2, - COMMENT.PREFIX, - BANNER.GITHUB, - COMMENT.END, - CHARS.EMPTY_LINE, - ].join(CHARS.NEWLINE); -} - -function renderTemplate(template: string, data: unknown): string { - return template.replace(TEMPLATE_REGEX, (_match, key) => { - const keys = key.split('.'); - let value = data; - - for (const k of keys) { - if (value && typeof value === 'object' && k in value) { - value = (value as Record)[k]; - } else { - return ''; - } - } - - return String(value); - }); -} - interface DiscoverFilesOptions { targetDirectory: string; includePatterns: readonly string[]; @@ -140,80 +44,6 @@ async function discoverFiles(options: DiscoverFilesOptions): Promise { return [...new Set(allFiles)]; } -interface ProcessFileOptions { - file: string; - targetDirectory: string; - baseData: BaseTemplateData; - bannerTemplate: string; - compiledTemplate: ReturnType | null; - useCustomTemplate: boolean; - separatorBetweenBannerAndContent: string; - prependAfterLicense: string; - commentType: string; -} - -async function processFile(options: ProcessFileOptions): Promise { - const { - file, - targetDirectory, - baseData, - bannerTemplate, - compiledTemplate, - useCustomTemplate, - separatorBetweenBannerAndContent, - prependAfterLicense, - commentType, - } = options; - - const content = await readFileText(file); - - if (content.startsWith(COMMENT.OPEN + commentType)) { - return; - } - - const relativePath = path.relative(targetDirectory, file).replace(/\\/g, '/'); - const fileData: FileTemplateData = { - ...baseData, - file: { relative: relativePath }, - commentType, - }; - - const banner = useCustomTemplate - ? compiledTemplate!(fileData) - : renderTemplate(bannerTemplate, fileData); - - const finalContent = banner + separatorBetweenBannerAndContent + prependAfterLicense + content; - await writeFileText(file, finalContent); -} - -interface LoadTemplateResult { - success: true; - template: string; -} - -interface LoadTemplateError { - success: false; -} - -async function loadBannerTemplate( - absoluteProjectRoot: string, - licenseTemplateFile: string | undefined, - commentType: string, -): Promise { - if (!licenseTemplateFile) { - return { success: true, template: buildDefaultBannerTemplate(commentType) }; - } - - const templatePath = path.join(absoluteProjectRoot, licenseTemplateFile); - try { - const template = await readFileText(templatePath); - return { success: true, template }; - } catch (error) { - logError(`Failed to read license template: ${templatePath}`, error); - return { success: false }; - } -} - const runExecutor: PromiseExecutor = async (options, context) => { const absoluteProjectRoot = resolveProjectPath(context); const targetDirectory = path.join( @@ -224,42 +54,21 @@ const runExecutor: PromiseExecutor = async (opt absoluteProjectRoot, options.packageJsonPath ?? DEFAULTS.PACKAGE_JSON, ); - const separatorBetweenBannerAndContent = - options.separatorBetweenBannerAndContent ?? CHARS.NEWLINE; + const separator = options.separatorBetweenBannerAndContent ?? '\n'; const prependAfterLicense = options.prependAfterLicense ?? ''; - const useCustomTemplate = !!options.licenseTemplateFile; const commentType = options.commentType ?? '!'; + const templatePath = options.licenseTemplateFile + ? path.join(absoluteProjectRoot, options.licenseTemplateFile) + : undefined; let pkg: PackageJson; try { - pkg = await readJson(packageJsonPath); + pkg = await readJson(packageJsonPath); } catch (error) { logError('Failed to read package.json', error); return { success: false }; } - const githubUrl = useCustomTemplate ? '' : extractGitHubUrl(pkg.repository, packageJsonPath); - - const templateResult = await loadBannerTemplate( - absoluteProjectRoot, - options.licenseTemplateFile, - commentType, - ); - if (!templateResult.success) { - return { success: false }; - } - const bannerTemplate = templateResult.template; - - const now = new Date(); - const baseData: BaseTemplateData = { - pkg, - date: now.toDateString(), - year: now.getFullYear(), - githubUrl, - eula: options.eulaUrl ?? '', - version: options.version ?? pkg.version, - }; - try { const files = await discoverFiles({ targetDirectory, @@ -269,22 +78,24 @@ const runExecutor: PromiseExecutor = async (opt logger.verbose(`Adding license headers to ${files.length} files...`); - const compiledTemplate = useCustomTemplate ? _.template(bannerTemplate) : null; + const renderBanner = await buildLicenseBannerRenderer({ + templatePath, + pkg, + eulaUrl: options.eulaUrl, + version: options.version, + commentType, + }); await Promise.all( - files.map((file) => - processFile({ - file, - targetDirectory, - baseData, - bannerTemplate, - compiledTemplate, - useCustomTemplate, - separatorBetweenBannerAndContent, - prependAfterLicense, + files.map(async (file) => { + const fileRelative = path.relative(targetDirectory, file).replace(/\\/g, '/'); + const banner = renderBanner(fileRelative); + await applyLicenseBannerToFile(file, banner, { commentType, - }), - ), + separator, + prependAfterLicense, + }); + }), ); logger.verbose('License headers added successfully'); diff --git a/packages/nx-infra-plugin/src/executors/compress/executor.ts b/packages/nx-infra-plugin/src/executors/compress/executor.ts index cd35c3bd88e4..79dbb7bb6d74 100644 --- a/packages/nx-infra-plugin/src/executors/compress/executor.ts +++ b/packages/nx-infra-plugin/src/executors/compress/executor.ts @@ -13,21 +13,12 @@ import { normalizeEol, ensureTrailingNewline, } from '../../utils/file-operations'; - -// NOTE: -// Removes the #DEBUG section from the code in the production build. -// E.g. removes the next code: -// //#DEBUG -// // some code. -// //#ENDDEBUG -// Between comment slashes (/) and # may be space symbols (count doesn't matter). -const REMOVE_DEBUG_REGEXP = /\/{2,}\s{0,}#DEBUG[\s\S]*?\/{2,}\s{0,}#ENDDEBUG/g; +import { stripDebug } from '../../utils/debug-strip'; function createCommentFilter(eulaUrl?: string) { - return function saveLicenseComments(_node: any, comment: { value: string }): boolean { + return function saveLicenseComments(_node: unknown, comment: { value: string }): boolean { return ( comment.value.charAt(0) === '!' - // Workaround for rrule, on v2.7.1 the space char was added to the license header https://github.com/jakubroztocil/rrule/commit/803c03b85ac074d92d443306805a68e104069c02#diff-a2a171449d862fe29692ce031981047d7ab755ae7f84c707aef80701b3ea0c80R1 || comment.value.startsWith(' !') || (!!eulaUrl && comment.value.indexOf(eulaUrl) > -1) ); @@ -86,10 +77,6 @@ async function runBeautify(content: string, eulaUrl?: string): Promise { return jsBeautify(uglifyResult.code); } -function stripDebugBlocks(content: string): string { - return content.replace(REMOVE_DEBUG_REGEXP, ''); -} - function normalizeOutput(content: string, trailingNewline: boolean): string { const eolNormalized = normalizeEol(content); return trailingNewline ? ensureTrailingNewline(eolNormalized) : eolNormalized; @@ -116,10 +103,10 @@ type CompressStrategy = (content: string, mode: ResolvedMode) => Promise const STRATEGIES: Record = { minify: async (content, { eulaUrl, trailingNewline }) => - normalizeOutput(await runMinify(stripDebugBlocks(content), eulaUrl), trailingNewline), + normalizeOutput(await runMinify(stripDebug(content), eulaUrl), trailingNewline), beautify: async (content, { eulaUrl, trailingNewline }) => normalizeOutput(await runBeautify(content, eulaUrl), trailingNewline), - 'strip-debug': async (content) => stripDebugBlocks(content), + 'strip-debug': async (content) => stripDebug(content), normalize: async (content, { trailingNewline }) => normalizeOutput(content, trailingNewline), }; diff --git a/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts b/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts index 9cbfce85caf8..770fa9957840 100644 --- a/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts +++ b/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts @@ -1,11 +1,12 @@ import { PromiseExecutor, logger } from '@nx/devkit'; import * as path from 'path'; import { glob } from 'glob'; -import { ConcatenateFilesExecutorSchema, TransformRule } from './schema'; +import { ConcatenateFilesExecutorSchema } from './schema'; import { resolveProjectPath, normalizeGlobPathForWindows } from '../../utils/path-resolver'; import { isWindowsOS, containsGlobPattern } from '../../utils/common'; import { logError } from '../../utils/error-handler'; -import { readFileText, writeFileText, exists } from '../../utils/file-operations'; +import { exists } from '../../utils/file-operations'; +import { concatToFile } from '../../utils/concat-content'; const ERROR_MESSAGES = { SOURCE_FILES_EMPTY: 'sourceFiles must contain at least one file', @@ -15,39 +16,6 @@ const ERROR_MESSAGES = { FAILED_TO_CONCATENATE: 'Failed to concatenate files', } as const; -function extractContent(content: string, pattern: string, flags: string): string { - try { - const regex = new RegExp(pattern, flags); - const match = regex.exec(content); - return match?.[1] ?? content; - } catch { - logger.verbose(`Invalid extractPattern: ${pattern}. Using original content.`); - return content; - } -} - -function applyTransforms(content: string, transforms: TransformRule[]): string { - return transforms.reduce((result, { find, replace, flags = 'g' }) => { - try { - return result.replace(new RegExp(find, flags), replace); - } catch { - logger.verbose(`Invalid transform pattern: ${find}. Skipping.`); - return result; - } - }, content); -} - -function normalizeLineEndings(content: string): string { - return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); -} - -function applyHeaderFooter(content: string, header?: string, footer?: string): string { - let result = content; - if (header) result = header + result; - if (footer) result = result + footer; - return result; -} - async function resolveGlobPattern(pattern: string, projectRoot: string): Promise { const sourcePath = path.resolve(projectRoot, pattern); const globPattern = isWindowsOS() ? normalizeGlobPathForWindows(sourcePath) : sourcePath; @@ -84,60 +52,6 @@ async function resolveSourceFiles(sourceFiles: string[], projectRoot: string): P return resolvedFiles; } -async function processFileContent( - filePath: string, - extractPattern?: string, - extractPatternFlags?: string, -): Promise { - const content = await readFileText(filePath); - - if (extractPattern) { - return extractContent(content, extractPattern, extractPatternFlags || 'gm'); - } - - return content; -} - -async function readAndProcessFiles( - files: string[], - projectRoot: string, - options: Pick, -): Promise { - return Promise.all( - files.map(async (filePath) => { - const content = await processFileContent( - filePath, - options.extractPattern, - options.extractPatternFlags, - ); - logger.verbose(`Processed: ${path.relative(projectRoot, filePath)}`); - return content; - }), - ); -} - -function buildOutput( - contents: string[], - options: Pick< - ConcatenateFilesExecutorSchema, - 'separator' | 'normalizeLineEndings' | 'header' | 'footer' | 'transforms' - >, -): string { - let output = contents.join(options.separator ?? '\n'); - - if (options.normalizeLineEndings !== false) { - output = normalizeLineEndings(output); - } - - output = applyHeaderFooter(output, options.header, options.footer); - - if (options.transforms?.length) { - output = applyTransforms(output, options.transforms); - } - - return output; -} - const runExecutor: PromiseExecutor = async (options, context) => { const projectRoot = resolveProjectPath(context); @@ -156,11 +70,17 @@ const runExecutor: PromiseExecutor = async (opti logger.verbose(`Concatenating ${resolvedFiles.length} files...`); - const contents = await readAndProcessFiles(resolvedFiles, projectRoot, options); - const output = buildOutput(contents, options); - const outputPath = path.resolve(projectRoot, options.outputFile); - await writeFileText(outputPath, output); + await concatToFile(outputPath, { + sourceFiles: resolvedFiles, + header: options.header, + footer: options.footer, + extractPattern: options.extractPattern, + extractPatternFlags: options.extractPatternFlags, + transforms: options.transforms, + normalizeLineEndings: options.normalizeLineEndings, + separator: options.separator, + }); logger.verbose(`Created: ${path.relative(projectRoot, outputPath)}`); return { success: true }; diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.e2e.spec.ts new file mode 100644 index 000000000000..db1c6eac03b9 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.e2e.spec.ts @@ -0,0 +1,112 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import executor from './executor'; +import { DtsBundleExecutorSchema } from './schema'; +import { createTempDir, cleanupTempDir, createMockContext } from '../../utils/test-utils'; +import { writeFileText, readFileText, writeJson } from '../../utils'; + +const LICENSE_TEMPLATE = `/*<%= commentType %> +* DevExtreme (<%= file.relative.replace(/\\\\/g, '/') %>) +* Version: <%= version %> +* Build date: <%= (new Date()).toDateString() %> +* +* Copyright (c) 2012 - <%= (new Date()).getFullYear() %> Developer Express Inc. ALL RIGHTS RESERVED +* Read about DevExtreme licensing here: <%= eula %> +*/ +`; + +const DX_ALL_CONTENT = `declare global { + interface JQuery {} + interface JQuery { + dxButton(): JQuery; + dxButton(options: string): any; + } +} + +declare namespace DevExpress { + export interface Options {} +} +`; + +const ALIASES_CONTENT = `declare namespace DevExpress { + export type EventObject = object; +} +`; + +const OPTIONS: DtsBundleExecutorSchema = { + bundleSources: ['./ts/dx.all.d.ts', './ts/aliases.d.ts'], + artifactPath: './artifacts/ts/dx.all.d.ts', + packagePath: './artifacts/npm/devextreme/bundles/dx.all.d.ts', + licenseTemplateFile: './build/gulp/license-header.txt', + eulaUrl: 'https://js.devexpress.com/Licensing/', +}; + +describe('DtsBundleExecutor E2E', () => { + let tempDir: string; + let context = createMockContext(); + let projectDir: string; + + beforeEach(async () => { + tempDir = createTempDir('nx-dts-bundle-e2e-'); + context = createMockContext({ root: tempDir }); + projectDir = path.join(tempDir, 'packages', 'test-lib'); + + fs.mkdirSync(path.join(projectDir, 'ts'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'build', 'gulp'), { recursive: true }); + + await writeJson(path.join(projectDir, 'package.json'), { + name: 'devextreme', + version: '26.1.0', + }); + + await writeFileText( + path.join(projectDir, 'build', 'gulp', 'license-header.txt'), + LICENSE_TEMPLATE, + ); + await writeFileText(path.join(projectDir, 'ts', 'dx.all.d.ts'), DX_ALL_CONTENT); + await writeFileText(path.join(projectDir, 'ts', 'aliases.d.ts'), ALIASES_CONTENT); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should produce artifact bundle with bang-license and stripped declare global wrapper', async () => { + const result = await executor(OPTIONS, context); + expect(result.success).toBe(true); + + const artifactContent = await readFileText( + path.join(projectDir, 'artifacts', 'ts', 'dx.all.d.ts'), + ); + + expect(artifactContent).toMatch(/^\/\*!/); + expect(artifactContent).toContain('DevExtreme (dx.all.d.ts)'); + expect(artifactContent).toContain('https://js.devexpress.com/Licensing/'); + + expect(artifactContent).not.toContain('declare global'); + expect(artifactContent).toContain('interface JQuery'); + + expect(artifactContent).toContain('DevExpress'); + expect(artifactContent).toContain('EventObject'); + }); + + it('should produce package bundle with star-license, footer, and stripped jQuery interface body', async () => { + const result = await executor(OPTIONS, context); + expect(result.success).toBe(true); + + const packageContent = await readFileText( + path.join(projectDir, 'artifacts', 'npm', 'devextreme', 'bundles', 'dx.all.d.ts'), + ); + + expect(packageContent).toMatch(/^\/\*\*/); + expect(packageContent).not.toMatch(/^\/\*!/); + expect(packageContent).toContain('DevExtreme (dx.all.d.ts)'); + + expect(packageContent).toContain('\nexport default DevExpress;'); + + expect(packageContent).toContain('interface JQuery'); + expect(packageContent).not.toContain('dxButton()'); + + expect(packageContent).toContain('EventObject'); + }); +}); diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts new file mode 100644 index 000000000000..0094ddaf3e4c --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts @@ -0,0 +1,68 @@ +import { PromiseExecutor, logger } from '@nx/devkit'; +import * as path from 'path'; +import { DtsBundleExecutorSchema } from './schema'; +import { resolveProjectPath } from '../../utils/path-resolver'; +import { logError } from '../../utils/error-handler'; +import { readJson, writeFileText } from '../../utils/file-operations'; +import { concatFiles } from '../../utils/concat-content'; +import { buildLicenseBannerRenderer } from '../../utils/license-banner'; + +interface PackageJson { + name: string; + version: string; + repository?: string | { url?: string }; +} + +const runExecutor: PromiseExecutor = async (options, context) => { + const projectRoot = resolveProjectPath(context); + const licenseTemplatePath = path.resolve(projectRoot, options.licenseTemplateFile); + + let pkg: PackageJson; + try { + pkg = await readJson(path.join(projectRoot, 'package.json')); + } catch (error) { + logError('Failed to read package.json', error); + return { success: false }; + } + + try { + const resolvedSources = options.bundleSources.map((s) => path.resolve(projectRoot, s)); + + const concatContent = await concatFiles({ + sourceFiles: resolvedSources, + normalizeLineEndings: false, + }); + + const bannerBase = { templatePath: licenseTemplatePath, pkg, eulaUrl: options.eulaUrl }; + + const [renderArtifactBanner, renderPackageBanner] = await Promise.all([ + buildLicenseBannerRenderer({ ...bannerBase, commentType: '!' }), + buildLicenseBannerRenderer({ ...bannerBase, commentType: '*' }), + ]); + + const artifactPath = path.resolve(projectRoot, options.artifactPath); + const artifactContent = concatContent.replace(/^declare global\s*\{([\s\S]*?)^\}/gm, '$1'); + const artifactBanner = renderArtifactBanner(path.basename(artifactPath)); + await writeFileText(artifactPath, artifactBanner + artifactContent); + logger.verbose(`Written artifact bundle: ${options.artifactPath}`); + + const packagePath = path.resolve(projectRoot, options.packagePath); + const packageContent = concatContent.replace( + /(interface JQuery\b[\s\S]*?\{)[\s\S]+?(\})/gm, + '$1$2', + ); + const packageBanner = renderPackageBanner(path.basename(packagePath)); + await writeFileText( + packagePath, + packageBanner + packageContent + '\nexport default DevExpress;', + ); + logger.verbose(`Written package bundle: ${options.packagePath}`); + + return { success: true }; + } catch (error) { + logError('DtsBundle executor failed', error); + return { success: false }; + } +}; + +export default runExecutor; diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json new file mode 100644 index 000000000000..6ce27b41b0fb --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/schema", + "type": "object", + "description": "Assemble TypeScript declaration bundle files from source.", + "properties": { + "bundleSources": { + "type": "array", + "items": { "type": "string" }, + "description": "Source .d.ts files to concatenate (relative to project root)." + }, + "artifactPath": { + "type": "string", + "description": "Output path for the TypeScript artifacts bundle (relative to project root)." + }, + "packagePath": { + "type": "string", + "description": "Output path for the npm package bundle (relative to project root)." + }, + "licenseTemplateFile": { + "type": "string", + "description": "Path to the license header template file (relative to project root)." + }, + "eulaUrl": { + "type": "string", + "description": "EULA URL embedded in the license header." + } + }, + "required": ["bundleSources", "artifactPath", "packagePath", "licenseTemplateFile", "eulaUrl"] +} diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/schema.ts b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.ts new file mode 100644 index 000000000000..ce6e070dbbfc --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.ts @@ -0,0 +1,7 @@ +export interface DtsBundleExecutorSchema { + bundleSources: string[]; + artifactPath: string; + packagePath: string; + licenseTemplateFile: string; + eulaUrl: string; +} diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts new file mode 100644 index 000000000000..6e69e56f2cb6 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts @@ -0,0 +1,154 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import executor from './executor'; +import { DtsModulesExecutorSchema } from './schema'; +import { createTempDir, cleanupTempDir, createMockContext } from '../../utils/test-utils'; +import { writeFileText, readFileText, writeJson } from '../../utils'; + +const LICENSE_TEMPLATE = `/*<%= commentType %> +* DevExtreme (<%= file.relative.replace(/\\\\/g, '/') %>) +* Version: <%= version %> +* Build date: <%= (new Date()).toDateString() %> +* +* Copyright (c) 2012 - <%= (new Date()).getFullYear() %> Developer Express Inc. ALL RIGHTS RESERVED +* Read about DevExtreme licensing here: <%= eula %> +*/ +`; + +const DEBUG_CONTENT = `export declare function accordion(): void; +//#DEBUG +export declare function debugHelper(): void; +//#ENDDEBUG +`; + +const LEGACY_HOVER = `import DevExpress from '../bundles/dx.all';`; +const LEGACY_DX_ALL_JS = `// This file is required to compile devextreme-angular`; + +const OPTIONS: DtsModulesExecutorSchema = { + sourceDir: './js', + outputDir: './artifacts/npm/devextreme', + legacyTemplatesDir: './build/npm-templates', + licenseTemplateFile: './build/gulp/license-header.txt', + eulaUrl: 'https://js.devexpress.com/Licensing/', +}; + +describe('DtsModulesExecutor E2E', () => { + let tempDir: string; + let context = createMockContext(); + let projectDir: string; + + beforeEach(async () => { + tempDir = createTempDir('nx-dts-modules-e2e-'); + context = createMockContext({ root: tempDir }); + projectDir = path.join(tempDir, 'packages', 'test-lib'); + + fs.mkdirSync(path.join(projectDir, 'js', 'ui'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'build', 'gulp'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'build', 'npm-templates', 'events'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'build', 'npm-templates', 'bundles'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'build', 'npm-templates', 'integration'), { + recursive: true, + }); + + await writeJson(path.join(projectDir, 'package.json'), { + name: 'devextreme', + version: '26.1.0', + }); + + await writeFileText( + path.join(projectDir, 'build', 'gulp', 'license-header.txt'), + LICENSE_TEMPLATE, + ); + + await writeFileText(path.join(projectDir, 'js', 'accordion.d.ts'), DEBUG_CONTENT); + await writeFileText( + path.join(projectDir, 'js', 'ui', 'button.d.ts'), + 'export declare class Button {}', + ); + + await writeFileText( + path.join(projectDir, 'build', 'npm-templates', 'events', 'hover.d.ts'), + LEGACY_HOVER, + ); + await writeFileText( + path.join(projectDir, 'build', 'npm-templates', 'bundles', 'dx.all.js'), + LEGACY_DX_ALL_JS, + ); + await writeFileText( + path.join(projectDir, 'build', 'npm-templates', 'integration', 'jquery.d.ts'), + "import 'jquery';", + ); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should produce the expected file tree (real .d.ts + legacy templates) with star-license banners and stripped debug blocks', async () => { + const result = await executor(OPTIONS, context); + expect(result.success).toBe(true); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + + const accordionContent = await readFileText(path.join(outDir, 'accordion.d.ts')); + expect(accordionContent).toMatch(/^\/\*\*/); + expect(accordionContent).toContain('DevExtreme (accordion.d.ts)'); + expect(accordionContent).not.toContain('#DEBUG'); + expect(accordionContent).not.toContain('debugHelper'); + expect(accordionContent).toContain('accordion'); + + const buttonContent = await readFileText(path.join(outDir, 'ui', 'button.d.ts')); + expect(buttonContent).toMatch(/^\/\*\*/); + expect(buttonContent).toContain('DevExtreme (ui/button.d.ts)'); + + const hoverContent = await readFileText(path.join(outDir, 'events', 'hover.d.ts')); + expect(hoverContent).toMatch(/^\/\*\*/); + expect(hoverContent).toContain('DevExtreme (events/hover.d.ts)'); + expect(hoverContent).toContain(LEGACY_HOVER); + + const jqContent = await readFileText(path.join(outDir, 'integration', 'jquery.d.ts')); + expect(jqContent).toMatch(/^\/\*\*/); + + const dxAllJsContent = await readFileText(path.join(outDir, 'bundles', 'dx.all.js')); + expect(dxAllJsContent).toMatch(/^\/\*\*/); + expect(dxAllJsContent).toContain('DevExtreme (dx.all.js)'); + expect(dxAllJsContent).not.toContain('DevExtreme (bundles/dx.all.js)'); + expect(dxAllJsContent).toContain(LEGACY_DX_ALL_JS); + + expect(fs.existsSync(path.join(outDir, 'bundles', 'dx.all.d.ts'))).toBe(false); + }); + + it('should overwrite legacy template when a real source d.ts exists at the same relative path', async () => { + const REAL_CONTENT = 'export declare function click(): void;'; + await writeFileText(path.join(projectDir, 'js', 'events', 'click.d.ts'), REAL_CONTENT); + await writeFileText( + path.join(projectDir, 'build', 'npm-templates', 'events', 'click.d.ts'), + 'import DevExpress from "../bundles/dx.all";', + ); + + const result = await executor(OPTIONS, context); + expect(result.success).toBe(true); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + const clickContent = await readFileText(path.join(outDir, 'events', 'click.d.ts')); + + expect(clickContent).toContain(REAL_CONTENT); + expect(clickContent).not.toContain('import DevExpress from "../bundles/dx.all"'); + }); + + it('should be idempotent across two runs', async () => { + const result1 = await executor(OPTIONS, context); + expect(result1.success).toBe(true); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + const contentAfterFirst = await readFileText(path.join(outDir, 'accordion.d.ts')); + + const result2 = await executor(OPTIONS, context); + expect(result2.success).toBe(true); + + const contentAfterSecond = await readFileText(path.join(outDir, 'accordion.d.ts')); + + expect(contentAfterFirst).toBe(contentAfterSecond); + expect((contentAfterFirst.match(/\/\*\*/g) ?? []).length).toBe(1); + }); +}); diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts new file mode 100644 index 000000000000..0652d9fe5745 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts @@ -0,0 +1,86 @@ +import { PromiseExecutor, logger } from '@nx/devkit'; +import * as path from 'path'; +import { glob } from 'glob'; +import { DtsModulesExecutorSchema } from './schema'; +import { resolveProjectPath } from '../../utils/path-resolver'; +import { logError } from '../../utils/error-handler'; +import { readJson, readFileText, writeFileText } from '../../utils/file-operations'; +import { copyDirectory } from '../../utils/copy-directory'; +import { buildLicenseBannerRenderer } from '../../utils/license-banner'; +import { stripDebug } from '../../utils/debug-strip'; + +interface PackageJson { + name: string; + version: string; + repository?: string | { url?: string }; +} + +const runExecutor: PromiseExecutor = async (options, context) => { + const projectRoot = resolveProjectPath(context); + const sourceDir = path.resolve(projectRoot, options.sourceDir); + const outputDir = path.resolve(projectRoot, options.outputDir); + const legacyTemplatesDir = path.resolve(projectRoot, options.legacyTemplatesDir); + const licenseTemplatePath = path.resolve(projectRoot, options.licenseTemplateFile); + + try { + await copyDirectory(legacyTemplatesDir, outputDir); + logger.verbose(`Copied legacy templates from ${options.legacyTemplatesDir}`); + + await copyDirectory(sourceDir, outputDir, { include: ['**/*.d.ts'] }); + logger.verbose(`Copied .d.ts files from ${options.sourceDir} to ${options.outputDir}`); + + let pkg: PackageJson; + try { + pkg = await readJson(path.join(projectRoot, 'package.json')); + } catch (pkgError) { + logError('Failed to read package.json', pkgError); + return { success: false }; + } + + const bannerBase = { templatePath: licenseTemplatePath, pkg, eulaUrl: options.eulaUrl }; + + const dtsFiles = await glob('**/*.d.ts', { cwd: outputDir, nodir: true, absolute: true }); + const jsFiles = await glob('bundles/*.js', { cwd: outputDir, nodir: true, absolute: true }); + + const renderBanner = await buildLicenseBannerRenderer({ ...bannerBase, commentType: '*' }); + + await Promise.all([ + ...dtsFiles.map(async (filePath) => { + const fileRelative = path.relative(outputDir, filePath).replace(/\\/g, '/'); + const banner = renderBanner(fileRelative); + const content = await readFileText(filePath); + await writeFileText(filePath, banner + content); + }), + ...jsFiles.map(async (filePath) => { + const fileRelative = path.basename(filePath); + const banner = renderBanner(fileRelative); + const content = await readFileText(filePath); + await writeFileText(filePath, banner + content); + }), + ]); + logger.verbose('Applied star-license banners'); + + const dtsNonBundles = dtsFiles.filter((f) => { + const rel = path.relative(outputDir, f).replace(/\\/g, '/'); + return !rel.startsWith('bundles/'); + }); + + await Promise.all( + dtsNonBundles.map(async (filePath) => { + const content = await readFileText(filePath); + const stripped = stripDebug(content); + if (stripped !== content) { + await writeFileText(filePath, stripped); + } + }), + ); + logger.verbose('Stripped debug blocks from .d.ts files'); + + return { success: true }; + } catch (error) { + logError('DtsModules executor failed', error); + return { success: false }; + } +}; + +export default runExecutor; diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/schema.json b/packages/nx-infra-plugin/src/executors/dts-modules/schema.json new file mode 100644 index 000000000000..018a58753d4e --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-modules/schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/schema", + "type": "object", + "description": "Assemble TypeScript declaration modules: copy, stamp with license, and strip debug blocks.", + "properties": { + "sourceDir": { + "type": "string", + "description": "Directory containing source .d.ts files (relative to project root)." + }, + "outputDir": { + "type": "string", + "description": "Output directory for assembled modules (relative to project root)." + }, + "legacyTemplatesDir": { + "type": "string", + "description": "Directory containing legacy template files to overlay on the output (relative to project root)." + }, + "licenseTemplateFile": { + "type": "string", + "description": "Path to the license header template file (relative to project root)." + }, + "eulaUrl": { + "type": "string", + "description": "EULA URL embedded in the license header." + } + }, + "required": ["sourceDir", "outputDir", "legacyTemplatesDir", "licenseTemplateFile", "eulaUrl"] +} diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts b/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts new file mode 100644 index 000000000000..d05ea69236b0 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts @@ -0,0 +1,7 @@ +export interface DtsModulesExecutorSchema { + sourceDir: string; + outputDir: string; + legacyTemplatesDir: string; + licenseTemplateFile: string; + eulaUrl: string; +} diff --git a/packages/nx-infra-plugin/src/utils/concat-content.ts b/packages/nx-infra-plugin/src/utils/concat-content.ts new file mode 100644 index 000000000000..bdd8ac5c2728 --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/concat-content.ts @@ -0,0 +1,79 @@ +import { readFileText, writeFileText } from './file-operations'; + +export interface ConcatOptions { + sourceFiles: string[]; + header?: string; + footer?: string; + extractPattern?: string; + extractPatternFlags?: string; + transforms?: { find: string; replace: string; flags?: string }[]; + normalizeLineEndings?: boolean; + separator?: string; +} + +function compileRegex(pattern: string, flags: string): RegExp { + try { + return new RegExp(pattern, flags); + } catch (error) { + throw new Error( + `Invalid regex pattern '${pattern}' (flags: '${flags}'): ${(error as Error).message}`, + ); + } +} + +function extractContent(content: string, pattern: string, flags: string): string { + const regex = compileRegex(pattern, flags); + const match = regex.exec(content); + return match?.[1] ?? content; +} + +function applyTransforms( + content: string, + transforms: { find: string; replace: string; flags?: string }[], +): string { + return transforms.reduce((result, { find, replace, flags = 'g' }) => { + return result.replace(compileRegex(find, flags), replace); + }, content); +} + +function normalizeLf(content: string): string { + return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function applyHeaderFooter(content: string, header?: string, footer?: string): string { + let result = content; + if (header) result = header + result; + if (footer) result = result + footer; + return result; +} + +export async function concatFiles(opts: ConcatOptions): Promise { + const contents = await Promise.all( + opts.sourceFiles.map(async (filePath) => { + const content = await readFileText(filePath); + if (opts.extractPattern) { + return extractContent(content, opts.extractPattern, opts.extractPatternFlags ?? 'gm'); + } + return content; + }), + ); + + let output = contents.join(opts.separator ?? '\n'); + + if (opts.normalizeLineEndings !== false) { + output = normalizeLf(output); + } + + output = applyHeaderFooter(output, opts.header, opts.footer); + + if (opts.transforms?.length) { + output = applyTransforms(output, opts.transforms); + } + + return output; +} + +export async function concatToFile(outputFile: string, opts: ConcatOptions): Promise { + const content = await concatFiles(opts); + await writeFileText(outputFile, content); +} diff --git a/packages/nx-infra-plugin/src/utils/copy-directory.ts b/packages/nx-infra-plugin/src/utils/copy-directory.ts new file mode 100644 index 000000000000..1ef63a98c704 --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/copy-directory.ts @@ -0,0 +1,32 @@ +import { glob } from 'glob'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { ensureDir } from './file-operations'; + +export async function copyDirectory( + sourceDir: string, + destDir: string, + options: { include?: string[]; exclude?: string[] } = {}, +): Promise { + const includePatterns = options.include ?? ['**/*']; + const excludePatterns = options.exclude ?? []; + + const relPaths = new Set(); + for (const pattern of includePatterns) { + const matches = await glob(pattern, { + cwd: sourceDir, + nodir: true, + ignore: excludePatterns, + }); + matches.forEach((m) => relPaths.add(m)); + } + + await Promise.all( + [...relPaths].map(async (relPath) => { + const src = path.join(sourceDir, relPath); + const dest = path.join(destDir, relPath); + await ensureDir(path.dirname(dest)); + await fs.copyFile(src, dest); + }), + ); +} diff --git a/packages/nx-infra-plugin/src/utils/debug-strip.ts b/packages/nx-infra-plugin/src/utils/debug-strip.ts new file mode 100644 index 000000000000..7bc194ff8551 --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/debug-strip.ts @@ -0,0 +1,5 @@ +export const REMOVE_DEBUG_REGEXP: RegExp = /\/{2,}\s{0,}#DEBUG[\s\S]*?\/{2,}\s{0,}#ENDDEBUG/g; + +export function stripDebug(content: string): string { + return content.replace(REMOVE_DEBUG_REGEXP, ''); +} diff --git a/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts b/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts new file mode 100644 index 000000000000..11042f627ae3 --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts @@ -0,0 +1,45 @@ +import * as path from 'path'; +import { buildLicenseBannerRenderer } from './license-banner'; +import { createTempDir, cleanupTempDir } from './test-utils'; +import { writeFileText } from './file-operations'; + +it('buildLicenseBannerRenderer compiles template once and returns a sync renderer per file', async () => { + const tempDir = createTempDir('nx-license-renderer-e2e-'); + try { + const templatePath = path.join(tempDir, 'license.txt'); + await writeFileText( + templatePath, + `/*<%= commentType %>\n* <%= file.relative %>\n* Version: <%= version %>\n*/\n`, + ); + const pkg = { name: 'test-pkg', version: '1.0.0' }; + const render = await buildLicenseBannerRenderer({ templatePath, pkg, commentType: '*' }); + + const banner1 = render('foo.d.ts'); + const banner2 = render('bar/baz.d.ts'); + + expect(banner1).toMatch(/^\/\*\*/); + expect(banner1).toContain('foo.d.ts'); + expect(banner1).toContain('Version: 1.0.0'); + expect(banner2).toMatch(/^\/\*\*/); + expect(banner2).toContain('bar/baz.d.ts'); + expect(banner2).not.toContain('foo.d.ts'); + } finally { + cleanupTempDir(tempDir); + } +}); + +it('buildLicenseBannerRenderer uses default banner template when templatePath is omitted', async () => { + const pkg = { + name: 'devextreme', + version: '26.1.0', + repository: 'https://github.com/DevExpress/DevExtreme', + }; + const render = await buildLicenseBannerRenderer({ pkg, commentType: '!' }); + + const banner = render('events/hover.d.ts'); + + expect(banner).toMatch(/^\/\*!/); + expect(banner).toContain('devextreme'); + expect(banner).toContain('26.1.0'); + expect(banner).toContain('Developer Express Inc.'); +}); diff --git a/packages/nx-infra-plugin/src/utils/license-banner.ts b/packages/nx-infra-plugin/src/utils/license-banner.ts new file mode 100644 index 000000000000..7e491bc6175e --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/license-banner.ts @@ -0,0 +1,125 @@ +import _ from 'lodash'; +import { readFileText, writeFileText } from './file-operations'; + +export interface LicenseBannerOptions { + templatePath?: string; + pkg: { name: string; version: string; repository?: string | { url?: string } }; + eulaUrl?: string; + version?: string; + commentType: '!' | '*'; +} + +const COMMENT_OPEN = '/*'; +const COMMENT_END = ' */'; +const COMMENT_PREFIX = ' *'; +const NEWLINE = '\n'; + +const TEMPLATE_REGEX = /<%=\s*(\w+(?:\.\w+)*)\s*%>/g; + +function buildDefaultBannerTemplate(commentType: string): string { + return [ + `${COMMENT_OPEN}${commentType}`, + `${COMMENT_PREFIX} <%= pkg.name %>`, + `${COMMENT_PREFIX} Version: <%= pkg.version %>`, + `${COMMENT_PREFIX} Build date: <%= date %>`, + COMMENT_PREFIX, + `${COMMENT_PREFIX} Copyright (c) 2012 - <%= year %> Developer Express Inc. ALL RIGHTS RESERVED`, + COMMENT_PREFIX, + `${COMMENT_PREFIX} This software may be modified and distributed under the terms`, + `${COMMENT_PREFIX} of the MIT license. See the LICENSE file in the root of the project for details.`, + COMMENT_PREFIX, + `${COMMENT_PREFIX} <%= githubUrl %>`, + COMMENT_END, + '', + ].join(NEWLINE); +} + +function renderTemplate(template: string, data: unknown): string { + return template.replace(TEMPLATE_REGEX, (_match, key: string) => { + const keys = key.split('.'); + let value: unknown = data; + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = (value as Record)[k]; + } else { + return ''; + } + } + return String(value); + }); +} + +function extractGitHubUrl(repository: string | { url?: string } | undefined): string { + if (!repository) { + throw new Error("Missing 'repository' field in package.json"); + } + const rawUrl = typeof repository === 'string' ? repository : repository.url; + if (!rawUrl) { + throw new Error("Invalid 'repository' format in package.json"); + } + return rawUrl.replace(/^git\+/, '').replace(/\.git$/, ''); +} + +export async function buildLicenseBannerRenderer( + opts: LicenseBannerOptions, +): Promise<(fileRelative: string) => string> { + const { templatePath, pkg, eulaUrl = '', commentType } = opts; + const resolvedVersion = opts.version ?? pkg.version; + const now = new Date(); + + if (templatePath) { + const templateText = await readFileText(templatePath); + const compiled = _.template(templateText); + return (fileRelative: string) => + compiled({ + commentType, + version: resolvedVersion, + eula: eulaUrl, + file: { relative: fileRelative }, + date: now.toDateString(), + year: now.getFullYear(), + pkg, + githubUrl: '', + }); + } + + const githubUrl = extractGitHubUrl(pkg.repository); + const defaultTemplate = buildDefaultBannerTemplate(commentType); + const templateData = { + pkg, + date: now.toDateString(), + year: now.getFullYear(), + githubUrl, + eula: eulaUrl, + version: resolvedVersion, + commentType, + }; + return (fileRelative: string) => + renderTemplate(defaultTemplate, { ...templateData, file: { relative: fileRelative } }); +} + +export async function renderLicenseBanner( + opts: LicenseBannerOptions, + fileRelative: string, +): Promise { + const renderer = await buildLicenseBannerRenderer(opts); + return renderer(fileRelative); +} + +export async function applyLicenseBannerToFile( + filePath: string, + banner: string, + options: { + commentType: '!' | '*'; + separator?: string; + prependAfterLicense?: string; + }, +): Promise { + const content = await readFileText(filePath); + if (content.startsWith(COMMENT_OPEN + options.commentType)) { + return; + } + const separator = options.separator ?? ''; + const prepend = options.prependAfterLicense ?? ''; + await writeFileText(filePath, banner + separator + prepend + content); +} From f0c668253098782d422197d1a810535fa6bff97b Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Thu, 30 Apr 2026 15:50:26 +0300 Subject: [PATCH 03/27] chore(devextreme): add build:npm:dist:package-json and build:npm:dist:meta targets --- packages/devextreme/project.json | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 0995e47bd294..d25d1e3421a6 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -968,6 +968,42 @@ "{projectRoot}/artifacts/npm/devextreme/bundles/dx.all.d.ts" ] }, + "build:npm:dist:package-json": { + "executor": "devextreme-nx-infra-plugin:prepare-package-json", + "options": { + "sourcePackageJson": "../devextreme-dist/package.json", + "distDirectory": "./artifacts/npm/devextreme-dist", + "versionFrom": "./package.json" + }, + "inputs": [ + "{workspaceRoot}/packages/devextreme-dist/package.json", + "{projectRoot}/package.json" + ], + "outputs": ["{projectRoot}/artifacts/npm/devextreme-dist/package.json"] + }, + "build:npm:dist:meta": { + "executor": "devextreme-nx-infra-plugin:copy-files", + "options": { + "files": [ + { + "from": "../devextreme-dist/README.md", + "to": "./artifacts/npm/devextreme-dist/README.md" + }, + { + "from": "../devextreme-dist/LICENSE.md", + "to": "./artifacts/npm/devextreme-dist/LICENSE.md" + } + ] + }, + "inputs": [ + "{workspaceRoot}/packages/devextreme-dist/README.md", + "{workspaceRoot}/packages/devextreme-dist/LICENSE.md" + ], + "outputs": [ + "{projectRoot}/artifacts/npm/devextreme-dist/README.md", + "{projectRoot}/artifacts/npm/devextreme-dist/LICENSE.md" + ] + }, "compress:npm-sources": { "executor": "devextreme-nx-infra-plugin:compress", "options": { From 6182c57f9927d105b3ea88a6dda3aa6f5dff7772 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Thu, 30 Apr 2026 16:27:44 +0300 Subject: [PATCH 04/27] feat(nx-infra-plugin): implement npm-assemble executor --- .../devextreme/build/npm-templates/.npmignore | 5 + packages/devextreme/project.json | 58 ++++++ packages/nx-infra-plugin/executors.json | 5 + .../executors/add-license-headers/executor.ts | 5 +- .../src/executors/babel-transform/executor.ts | 7 +- .../executors/build-typescript/executor.ts | 12 +- .../src/executors/compress/executor.ts | 16 +- .../executors/concatenate-files/executor.ts | 6 +- .../src/executors/copy-files/executor.ts | 8 +- .../create-dual-mode-manifest/executor.ts | 5 +- .../src/executors/dts-modules/executor.ts | 7 +- .../npm-assemble/executor.e2e.spec.ts | 137 +++++++++++++ .../src/executors/npm-assemble/executor.ts | 186 ++++++++++++++++++ .../src/executors/npm-assemble/schema.json | 54 +++++ .../src/executors/npm-assemble/schema.ts | 11 ++ .../src/utils/copy-directory.ts | 4 +- .../src/utils/path-resolver.ts | 5 + 17 files changed, 496 insertions(+), 35 deletions(-) create mode 100644 packages/devextreme/build/npm-templates/.npmignore create mode 100644 packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts create mode 100644 packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts create mode 100644 packages/nx-infra-plugin/src/executors/npm-assemble/schema.json create mode 100644 packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts diff --git a/packages/devextreme/build/npm-templates/.npmignore b/packages/devextreme/build/npm-templates/.npmignore new file mode 100644 index 000000000000..1a455a8af88f --- /dev/null +++ b/packages/devextreme/build/npm-templates/.npmignore @@ -0,0 +1,5 @@ +dist/js +dist/ts +!dist/css +!/scss/bundles/*.scss +project.json \ No newline at end of file diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index d25d1e3421a6..906af06e8941 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1004,6 +1004,64 @@ "{projectRoot}/artifacts/npm/devextreme-dist/LICENSE.md" ] }, + "build:npm:assemble": { + "executor": "devextreme-nx-infra-plugin:npm-assemble", + "options": { + "transpiledDir": "./artifacts/transpiled-esm-npm", + "jsSrcDir": "./js", + "licenseSrcDir": "./license", + "npmBinDir": "./build/npm-bin", + "webpackConfig": "./webpack.config.js", + "artifactsDir": "./artifacts", + "outputDir": "./artifacts/npm/devextreme", + "licenseTemplateFile": "./build/gulp/license-header.txt", + "eulaUrl": "https://js.devexpress.com/Licensing/" + }, + "inputs": [ + "{projectRoot}/artifacts/transpiled-esm-npm/**/*", + "{projectRoot}/js/**/*.json", + "{projectRoot}/license/**/*", + "{projectRoot}/build/npm-bin/**/*", + "{projectRoot}/webpack.config.js", + "{projectRoot}/artifacts/js/**/*", + "{projectRoot}/artifacts/css/**/*", + "{projectRoot}/artifacts/ts/**/*", + "{projectRoot}/build/gulp/license-header.txt" + ], + "outputs": [ + "{projectRoot}/artifacts/npm/devextreme/**/*" + ] + }, + "build:npm:root-package-json": { + "executor": "devextreme-nx-infra-plugin:prepare-package-json", + "options": { + "sourcePackageJson": "./package.json", + "distDirectory": "./artifacts/npm/devextreme", + "setName": "devextreme", + "removeFields": ["devDependencies", "publishConfig", "scripts"] + }, + "inputs": [ + "{projectRoot}/package.json" + ], + "outputs": ["{projectRoot}/artifacts/npm/devextreme/package.json"] + }, + "build:npm:meta": { + "executor": "devextreme-nx-infra-plugin:copy-files", + "options": { + "files": [ + { "from": "../../README.md", "to": "./artifacts/npm/devextreme/README.md" }, + { "from": "./build/npm-templates/.npmignore", "to": "./artifacts/npm/devextreme/.npmignore" } + ] + }, + "inputs": [ + "{workspaceRoot}/README.md", + "{projectRoot}/build/npm-templates/.npmignore" + ], + "outputs": [ + "{projectRoot}/artifacts/npm/devextreme/README.md", + "{projectRoot}/artifacts/npm/devextreme/.npmignore" + ] + }, "compress:npm-sources": { "executor": "devextreme-nx-infra-plugin:compress", "options": { diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index e3136ab70dbf..599f66ad9633 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -99,6 +99,11 @@ "implementation": "./src/executors/dts-bundle/executor", "schema": "./src/executors/dts-bundle/schema.json", "description": "Assemble TypeScript declaration bundle files from source" + }, + "npm-assemble": { + "implementation": "./src/executors/npm-assemble/executor", + "schema": "./src/executors/npm-assemble/schema.json", + "description": "Assemble npm package from transpiled sources, bin, license, dist files and meta" } } } diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts index dccff8c50102..3afe40d660d9 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts @@ -2,8 +2,7 @@ import { PromiseExecutor, logger } from '@nx/devkit'; import * as path from 'path'; import { glob } from 'glob'; import { AddLicenseHeadersExecutorSchema } from './schema'; -import { resolveProjectPath, normalizeGlobPathForWindows } from '../../utils/path-resolver'; -import { isWindowsOS } from '../../utils/common'; +import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; import { logError } from '../../utils/error-handler'; import { readJson } from '../../utils/file-operations'; import { buildLicenseBannerRenderer, applyLicenseBannerToFile } from '../../utils/license-banner'; @@ -32,7 +31,7 @@ async function discoverFiles(options: DiscoverFilesOptions): Promise { const patterns = includePatterns.map((pattern) => { const fullPath = path.join(targetDirectory, pattern); - return isWindowsOS() ? normalizeGlobPathForWindows(fullPath) : fullPath; + return toPosixPath(fullPath); }); const allFiles: string[] = []; 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 8809328cdb68..7a0aa4773a69 100644 --- a/packages/nx-infra-plugin/src/executors/babel-transform/executor.ts +++ b/packages/nx-infra-plugin/src/executors/babel-transform/executor.ts @@ -4,8 +4,7 @@ import * as fs from 'fs-extra'; import * as babel from '@babel/core'; import { glob } from 'glob'; import { BabelTransformExecutorSchema } from './schema'; -import { resolveProjectPath, normalizeGlobPathForWindows } from '../../utils/path-resolver'; -import { isWindowsOS } from '../../utils/common'; +import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; function removeDebugBlocks(content: string): string { return content.replace(/\/{2,}\s*#DEBUG[\s\S]*?\/{2,}\s*#ENDDEBUG/g, ''); @@ -91,12 +90,12 @@ const runExecutor: PromiseExecutor = async (option const renameExtensions = options.renameExtensions ?? {}; const sourcePath = path.join(projectRoot, options.sourcePattern); - const globPattern = isWindowsOS() ? normalizeGlobPathForWindows(sourcePath) : sourcePath; + const globPattern = toPosixPath(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; + return toPosixPath(resolved); }); const sourceFiles = await glob(globPattern, { 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 21a45e0bbc50..ca1dedf1af78 100644 --- a/packages/nx-infra-plugin/src/executors/build-typescript/executor.ts +++ b/packages/nx-infra-plugin/src/executors/build-typescript/executor.ts @@ -5,7 +5,11 @@ 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 { + resolveProjectPath, + normalizeGlobPathForWindows, + toPosixPath, +} from '../../utils/path-resolver'; import { isWindowsOS } from '../../utils/common'; import { logError } from '../../utils/error-handler'; import { exists, ensureDir, writeFileText } from '../../utils/file-operations'; @@ -115,13 +119,11 @@ async function resolveSourceFiles( srcPattern: string, excludePatterns: string[], ): Promise { - const globPattern = isWindowsOS() - ? normalizeGlobPathForWindows(path.join(projectRoot, srcPattern)) - : path.join(projectRoot, srcPattern); + const globPattern = toPosixPath(path.join(projectRoot, srcPattern)); const resolvedExcludes = excludePatterns.map((pattern) => { const result = path.join(projectRoot, pattern); - return isWindowsOS() ? normalizeGlobPathForWindows(result) : result; + return toPosixPath(result); }); const files = await glob(globPattern, { diff --git a/packages/nx-infra-plugin/src/executors/compress/executor.ts b/packages/nx-infra-plugin/src/executors/compress/executor.ts index 79dbb7bb6d74..174bda2fa5a2 100644 --- a/packages/nx-infra-plugin/src/executors/compress/executor.ts +++ b/packages/nx-infra-plugin/src/executors/compress/executor.ts @@ -5,8 +5,8 @@ import { js as jsBeautify } from 'js-beautify'; import { glob } from 'glob'; import { minimatch } from 'minimatch'; import { CompressExecutorSchema, CompressMode, CompressModeName } from './schema'; -import { resolveProjectPath, normalizeGlobPathForWindows } from '../../utils/path-resolver'; -import { isWindowsOS, containsGlobPattern } from '../../utils/common'; +import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; +import { containsGlobPattern } from '../../utils/common'; import { readFileText, writeFileText, @@ -78,8 +78,8 @@ async function runBeautify(content: string, eulaUrl?: string): Promise { } function normalizeOutput(content: string, trailingNewline: boolean): string { - const eolNormalized = normalizeEol(content); - return trailingNewline ? ensureTrailingNewline(eolNormalized) : eolNormalized; + const normalized = normalizeEol(content); + return trailingNewline ? ensureTrailingNewline(normalized) : normalized; } type ResolvedMode = { @@ -126,20 +126,18 @@ async function expandFileList( exclude: string[] | undefined, projectRoot: string, ): Promise { - const toPosixIfWindows = (p: string) => (isWindowsOS() ? normalizeGlobPathForWindows(p) : p); - - const ignorePatterns = exclude?.map((p) => toPosixIfWindows(path.resolve(projectRoot, p))); + const ignorePatterns = exclude?.map((p) => toPosixPath(path.resolve(projectRoot, p))); const isExcluded = (absolutePath: string): boolean => !!ignorePatterns?.some((pattern) => - minimatch(toPosixIfWindows(absolutePath), pattern, { dot: true }), + minimatch(toPosixPath(absolutePath), pattern, { dot: true }), ); const resolved: string[] = []; for (const entry of files) { const absolute = path.resolve(projectRoot, entry); if (containsGlobPattern(entry)) { - const pattern = toPosixIfWindows(absolute); + const pattern = toPosixPath(absolute); const matches = await glob(pattern, { nodir: true, ignore: ignorePatterns }); resolved.push(...matches); } else if (!isExcluded(absolute)) { diff --git a/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts b/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts index 770fa9957840..fe34959f49b9 100644 --- a/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts +++ b/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts @@ -2,8 +2,8 @@ import { PromiseExecutor, logger } from '@nx/devkit'; import * as path from 'path'; import { glob } from 'glob'; import { ConcatenateFilesExecutorSchema } from './schema'; -import { resolveProjectPath, normalizeGlobPathForWindows } from '../../utils/path-resolver'; -import { isWindowsOS, containsGlobPattern } from '../../utils/common'; +import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; +import { containsGlobPattern } from '../../utils/common'; import { logError } from '../../utils/error-handler'; import { exists } from '../../utils/file-operations'; import { concatToFile } from '../../utils/concat-content'; @@ -18,7 +18,7 @@ const ERROR_MESSAGES = { async function resolveGlobPattern(pattern: string, projectRoot: string): Promise { const sourcePath = path.resolve(projectRoot, pattern); - const globPattern = isWindowsOS() ? normalizeGlobPathForWindows(sourcePath) : sourcePath; + const globPattern = toPosixPath(sourcePath); const files = await glob(globPattern, { nodir: true }); if (files.length === 0) { diff --git a/packages/nx-infra-plugin/src/executors/copy-files/executor.ts b/packages/nx-infra-plugin/src/executors/copy-files/executor.ts index 40744987027c..fffe6b28480f 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/executor.ts +++ b/packages/nx-infra-plugin/src/executors/copy-files/executor.ts @@ -3,8 +3,8 @@ import * as path from 'path'; import { stat } from 'fs/promises'; import { glob } from 'glob'; import { CopyFilesExecutorSchema } from './schema'; -import { resolveProjectPath, normalizeGlobPathForWindows } from '../../utils/path-resolver'; -import { isWindowsOS, containsGlobPattern } from '../../utils/common'; +import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; +import { containsGlobPattern } from '../../utils/common'; import { logError } from '../../utils/error-handler'; import { copyFile, copyRecursive, exists, ensureDir } from '../../utils/file-operations'; @@ -20,8 +20,8 @@ async function copyGlobPatternFiles( destPath: string, excludePatterns: string[] = [], ): Promise<{ success: boolean }> { - const globPattern = isWindowsOS() ? normalizeGlobPathForWindows(sourcePath) : sourcePath; - const ignore = isWindowsOS() ? excludePatterns.map(normalizeGlobPathForWindows) : excludePatterns; + const globPattern = toPosixPath(sourcePath); + const ignore = excludePatterns.map(toPosixPath); const files = await glob(globPattern, { nodir: true, ignore }); if (files.length === 0) { 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 index 0ce9a4e6c8c3..4a363ef66690 100644 --- 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 @@ -4,8 +4,7 @@ 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 { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; import { logError } from '../../utils/error-handler'; import { exists, ensureDir, writeFileText } from '../../utils/file-operations'; @@ -100,7 +99,7 @@ async function validateDirectories(esmDir: string, cjsDir: string): Promise { const pattern = path.join(esmDir, '**/*.js'); - const globPattern = isWindowsOS() ? normalizeGlobPathForWindows(pattern) : pattern; + const globPattern = toPosixPath(pattern); return glob(globPattern, { nodir: true, diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts index 0652d9fe5745..dd8c5fea5f6e 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts @@ -2,7 +2,7 @@ import { PromiseExecutor, logger } from '@nx/devkit'; import * as path from 'path'; import { glob } from 'glob'; import { DtsModulesExecutorSchema } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; +import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; import { logError } from '../../utils/error-handler'; import { readJson, readFileText, writeFileText } from '../../utils/file-operations'; import { copyDirectory } from '../../utils/copy-directory'; @@ -39,8 +39,9 @@ const runExecutor: PromiseExecutor = async (options, c const bannerBase = { templatePath: licenseTemplatePath, pkg, eulaUrl: options.eulaUrl }; - const dtsFiles = await glob('**/*.d.ts', { cwd: outputDir, nodir: true, absolute: true }); - const jsFiles = await glob('bundles/*.js', { cwd: outputDir, nodir: true, absolute: true }); + const cwd = toPosixPath(outputDir); + const dtsFiles = await glob('**/*.d.ts', { cwd, nodir: true, absolute: true }); + const jsFiles = await glob('bundles/*.js', { cwd, nodir: true, absolute: true }); const renderBanner = await buildLicenseBannerRenderer({ ...bannerBase, commentType: '*' }); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts new file mode 100644 index 000000000000..b6c9e511f3ca --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts @@ -0,0 +1,137 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import executor from './executor'; +import { NpmAssembleExecutorSchema } from './schema'; +import { createTempDir, cleanupTempDir, createMockContext } from '../../utils/test-utils'; +import { writeFileText, readFileText } from '../../utils'; + +const LICENSE_TEMPLATE = `/*<%= commentType %> +* DevExtreme (<%= file.relative %>) +* Version: <%= version %> +* Build date: <%= date %> +* +* Copyright (c) 2012 - <%= year %> Developer Express Inc. ALL RIGHTS RESERVED +* Read about DevExtreme licensing here: <%= eula %> +*/ +`; + +const OPTIONS: NpmAssembleExecutorSchema = { + transpiledDir: './artifacts/transpiled-esm-npm', + jsSrcDir: './js', + licenseSrcDir: './license', + npmBinDir: './build/npm-bin', + webpackConfig: './webpack.config.js', + artifactsDir: './artifacts', + outputDir: './artifacts/npm/devextreme', + licenseTemplateFile: './build/gulp/license-header.txt', + eulaUrl: 'https://js.devexpress.com/Licensing/', +}; + +describe('NpmAssembleExecutor E2E', () => { + let tempDir: string; + let context = createMockContext(); + let projectDir: string; + + beforeEach(async () => { + tempDir = createTempDir('nx-npm-assemble-e2e-'); + context = createMockContext({ root: tempDir }); + projectDir = path.join(tempDir, 'packages', 'test-lib'); + + fs.mkdirSync(path.join(projectDir, 'artifacts', 'transpiled-esm-npm'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'js'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'license'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'build', 'npm-bin'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'artifacts'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'build', 'gulp'), { recursive: true }); + + await writeFileText( + path.join(projectDir, 'package.json'), + JSON.stringify({ name: 'devextreme', version: '26.1.0' }), + ); + await writeFileText( + path.join(projectDir, 'build', 'gulp', 'license-header.txt'), + LICENSE_TEMPLATE, + ); + await writeFileText(path.join(projectDir, 'webpack.config.js'), 'module.exports = {};'); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should copy transpiled JS sources with license filter and apply star-license banners', async () => { + const transpiledDir = path.join(projectDir, 'artifacts', 'transpiled-esm-npm'); + fs.mkdirSync(path.join(transpiledDir, 'esm'), { recursive: true }); + fs.mkdirSync(path.join(transpiledDir, 'bundles'), { recursive: true }); + fs.mkdirSync(path.join(transpiledDir, 'esm', 'license'), { recursive: true }); + + await writeFileText(path.join(transpiledDir, 'esm', 'button.js'), 'export class Button {}'); + await writeFileText(path.join(transpiledDir, 'bundles', 'dx.all.js'), 'var dx = {};'); + await writeFileText( + path.join(transpiledDir, 'esm', 'license', 'license_validation_internal.js'), + 'var v = {};', + ); + + const result = await executor(OPTIONS, context); + expect(result.success).toBe(true); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + + const buttonContent = await readFileText(path.join(outDir, 'esm', 'button.js')); + expect(buttonContent).toMatch(/^\/\*\*/); + expect(buttonContent).toContain('esm/button.js'); + + expect(fs.existsSync(path.join(outDir, 'bundles', 'dx.all.js'))).toBe(false); + expect( + fs.existsSync(path.join(outDir, 'esm', 'license', 'license_validation_internal.js')), + ).toBe(false); + }); + + it('should copy license dir and npm-bin scripts with LF line endings', async () => { + await writeFileText( + path.join(projectDir, 'license', 'LICENSE.txt'), + 'DevExtreme License\r\nCopyright 2024\r\n', + ); + await writeFileText( + path.join(projectDir, 'build', 'npm-bin', 'install.js'), + 'var a = 1;\r\nvar b = 2;\r\n', + ); + + const result = await executor(OPTIONS, context); + expect(result.success).toBe(true); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + + const licenseContent = await readFileText(path.join(outDir, 'license', 'LICENSE.txt')); + expect(licenseContent).not.toContain('\r\n'); + expect(licenseContent).toContain('\n'); + + const binContent = await readFileText(path.join(outDir, 'bin', 'install.js')); + expect(binContent).not.toContain('\r\n'); + expect(binContent).toContain('\n'); + }); + + it('should copy dist files into outputDir/dist with the gulp-equivalent excludes', async () => { + const artifactsDir = path.join(projectDir, 'artifacts'); + fs.mkdirSync(path.join(artifactsDir, 'js'), { recursive: true }); + fs.mkdirSync(path.join(artifactsDir, 'css'), { recursive: true }); + fs.mkdirSync(path.join(artifactsDir, 'ts'), { recursive: true }); + + await writeFileText(path.join(artifactsDir, 'js', 'dx.all.js'), 'var dx = {};'); + await writeFileText(path.join(artifactsDir, 'js', 'jquery.js'), 'var $ = {};'); + await writeFileText(path.join(artifactsDir, 'css', 'dx.light.css'), '.dx { }'); + await writeFileText(path.join(artifactsDir, 'css', 'dx-diagram.css'), '.diagram { }'); + await writeFileText(path.join(artifactsDir, 'ts', 'dx.all.d.ts'), 'export {}'); + + const result = await executor(OPTIONS, context); + expect(result.success).toBe(true); + + const distDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme', 'dist'); + + expect(fs.existsSync(path.join(distDir, 'js', 'dx.all.js'))).toBe(true); + expect(fs.existsSync(path.join(distDir, 'js', 'jquery.js'))).toBe(false); + expect(fs.existsSync(path.join(distDir, 'css', 'dx.light.css'))).toBe(true); + expect(fs.existsSync(path.join(distDir, 'css', 'dx-diagram.css'))).toBe(false); + expect(fs.existsSync(path.join(distDir, 'ts', 'dx.all.d.ts'))).toBe(true); + }); +}); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts new file mode 100644 index 000000000000..382a2ed5dcda --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts @@ -0,0 +1,186 @@ +import { PromiseExecutor, logger } from '@nx/devkit'; +import * as path from 'path'; +import { glob } from 'glob'; +import { NpmAssembleExecutorSchema } from './schema'; +import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; +import { logError } from '../../utils/error-handler'; +import { + readFileText, + writeFileText, + copyFile, + readJson, + normalizeEol, + ensureTrailingNewline, +} from '../../utils/file-operations'; +import { copyDirectory } from '../../utils/copy-directory'; +import { buildLicenseBannerRenderer, applyLicenseBannerToFile } from '../../utils/license-banner'; + +interface PackageJson { + name: string; + version: string; + repository?: string | { url?: string }; +} + +const SRC_JS_EXCLUDES = [ + 'bundles/*.js', + 'cjs/bundles/**/*', + 'esm/bundles/**/*', + 'bundles/modules/parts/*.js', + 'viz/vector_map.utils/*.js', + 'viz/docs/*.js', + '**/license/license_validation_internal.js', +]; + +const DIST_EXCLUDES = [ + 'transpiled**/**/*', + 'npm/**/*.*', + 'ts/jquery*', + 'ts/knockout*', + 'ts/globalize*', + 'ts/cldr*', + 'css/dx-diagram.*', + 'css/dx-gantt.*', + 'js/knockout*', + 'js/cldr/*.*', + 'js/cldr*', + 'js/globalize/*.*', + 'js/globalize*', + 'js/dx-exceljs-fork*', + 'js/file-saver*', + 'js/jquery*', + 'js/jspdf*', + 'js/jspdf-autotable*', + 'js/jszip*', + 'js/dx.custom*', + 'js/dx.viz*', + 'js/dx.web*', + 'js/dx-diagram*', + 'js/dx-gantt*', + 'js/dx-quill*', +]; + +async function copySourceJs(transpiledDir: string, outputDir: string): Promise { + await copyDirectory(transpiledDir, outputDir, { + include: ['**/*.js'], + exclude: SRC_JS_EXCLUDES, + }); +} + +async function copyEsmPackageJsonFiles(transpiledDir: string, outputDir: string): Promise { + await copyDirectory(transpiledDir, outputDir, { + include: ['**/*.json'], + exclude: ['viz/vector_map.utils/**'], + }); +} + +async function copyJsSrcJsonFiles(jsSrcDir: string, outputDir: string): Promise { + await copyDirectory(jsSrcDir, outputDir, { + include: ['**/*.json'], + exclude: ['viz/vector_map.utils/**'], + }); +} + +async function normalizeDirectoryEol(dirPath: string, pattern: string): Promise { + const cwd = toPosixPath(dirPath); + const files = await glob(pattern, { cwd, nodir: true, absolute: true }); + await Promise.all( + files.map(async (filePath) => { + const content = await readFileText(filePath); + await writeFileText(filePath, ensureTrailingNewline(normalizeEol(content))); + }), + ); +} + +async function copyLicenseFiles(licenseSrcDir: string, outputDir: string): Promise { + const licenseOutDir = path.join(outputDir, 'license'); + await copyDirectory(licenseSrcDir, licenseOutDir); + await normalizeDirectoryEol(licenseOutDir, '**/*'); +} + +async function copyNpmBinFiles(npmBinDir: string, outputDir: string): Promise { + const binOutDir = path.join(outputDir, 'bin'); + await copyDirectory(npmBinDir, binOutDir, { include: ['*.js'] }); + await normalizeDirectoryEol(binOutDir, '*.js'); +} + +async function copyDistFiles(artifactsDir: string, outputDir: string): Promise { + await copyDirectory(artifactsDir, path.join(outputDir, 'dist'), { + include: ['**/*'], + exclude: DIST_EXCLUDES, + }); +} + +async function applyHeadersToSourceJs( + outputDir: string, + licenseTemplatePath: string, + pkg: PackageJson, + eulaUrl: string, +): Promise { + const renderBanner = await buildLicenseBannerRenderer({ + templatePath: licenseTemplatePath, + pkg, + eulaUrl, + commentType: '*', + }); + const cwd = toPosixPath(outputDir); + const jsFiles = await glob('**/*.js', { + cwd, + nodir: true, + absolute: true, + ignore: [...SRC_JS_EXCLUDES, 'dist/**/*', 'bin/**/*', 'license/**/*'], + }); + await Promise.all( + jsFiles.map(async (filePath) => { + const fileRelative = path.relative(outputDir, filePath).replace(/\\/g, '/'); + const banner = renderBanner(fileRelative); + await applyLicenseBannerToFile(filePath, banner, { commentType: '*' }); + }), + ); +} + +const runExecutor: PromiseExecutor = async (options, context) => { + const projectRoot = resolveProjectPath(context); + const transpiledDir = path.resolve(projectRoot, options.transpiledDir); + const jsSrcDir = path.resolve(projectRoot, options.jsSrcDir); + const licenseSrcDir = path.resolve(projectRoot, options.licenseSrcDir); + const npmBinDir = path.resolve(projectRoot, options.npmBinDir); + const webpackConfigSrc = path.resolve(projectRoot, options.webpackConfig); + const artifactsDir = path.resolve(projectRoot, options.artifactsDir); + const outputDir = path.resolve(projectRoot, options.outputDir); + const licenseTemplatePath = path.resolve(projectRoot, options.licenseTemplateFile); + + try { + const pkg = await readJson(path.join(projectRoot, 'package.json')); + + await copySourceJs(transpiledDir, outputDir); + logger.verbose(`Copied source JS from ${options.transpiledDir}`); + + await copyEsmPackageJsonFiles(transpiledDir, outputDir); + logger.verbose(`Copied ESM package.json files from ${options.transpiledDir}`); + + await copyJsSrcJsonFiles(jsSrcDir, outputDir); + logger.verbose(`Copied js/**/*.json from ${options.jsSrcDir}`); + + await copyLicenseFiles(licenseSrcDir, outputDir); + logger.verbose(`Copied license files from ${options.licenseSrcDir}`); + + await copyNpmBinFiles(npmBinDir, outputDir); + logger.verbose(`Copied npm-bin scripts from ${options.npmBinDir}`); + + await copyFile(webpackConfigSrc, path.join(outputDir, 'bin', path.basename(webpackConfigSrc))); + logger.verbose(`Copied ${options.webpackConfig} to bin/`); + + await copyDistFiles(artifactsDir, outputDir); + logger.verbose(`Copied dist files from ${options.artifactsDir}`); + + await applyHeadersToSourceJs(outputDir, licenseTemplatePath, pkg, options.eulaUrl); + logger.verbose('Applied star-license banners to source JS files'); + + return { success: true }; + } catch (error) { + logError('NpmAssemble executor failed', error); + return { success: false }; + } +}; + +export default runExecutor; diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json new file mode 100644 index 000000000000..5883c8248869 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/schema", + "type": "object", + "description": "Assemble npm package from transpiled sources, bin, license, dist files and meta.", + "properties": { + "transpiledDir": { + "type": "string", + "description": "Directory containing transpiled ESM+CJS sources (relative to project root)." + }, + "jsSrcDir": { + "type": "string", + "description": "Directory containing js source JSON files (relative to project root)." + }, + "licenseSrcDir": { + "type": "string", + "description": "Directory containing license files to copy (relative to project root)." + }, + "npmBinDir": { + "type": "string", + "description": "Directory containing npm bin scripts (relative to project root)." + }, + "webpackConfig": { + "type": "string", + "description": "Path to the webpack config file to copy into bin/ (relative to project root)." + }, + "artifactsDir": { + "type": "string", + "description": "Artifacts directory containing js, css, ts sub-dirs for dist copy (relative to project root)." + }, + "outputDir": { + "type": "string", + "description": "Output directory for the assembled npm package (relative to project root)." + }, + "licenseTemplateFile": { + "type": "string", + "description": "Path to the license header template file (relative to project root)." + }, + "eulaUrl": { + "type": "string", + "description": "EULA URL embedded in the license header." + } + }, + "required": [ + "transpiledDir", + "jsSrcDir", + "licenseSrcDir", + "npmBinDir", + "webpackConfig", + "artifactsDir", + "outputDir", + "licenseTemplateFile", + "eulaUrl" + ] +} diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts new file mode 100644 index 000000000000..0084ee250e97 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts @@ -0,0 +1,11 @@ +export interface NpmAssembleExecutorSchema { + transpiledDir: string; + jsSrcDir: string; + licenseSrcDir: string; + npmBinDir: string; + webpackConfig: string; + artifactsDir: string; + outputDir: string; + licenseTemplateFile: string; + eulaUrl: string; +} diff --git a/packages/nx-infra-plugin/src/utils/copy-directory.ts b/packages/nx-infra-plugin/src/utils/copy-directory.ts index 1ef63a98c704..773e81518a5e 100644 --- a/packages/nx-infra-plugin/src/utils/copy-directory.ts +++ b/packages/nx-infra-plugin/src/utils/copy-directory.ts @@ -2,6 +2,7 @@ import { glob } from 'glob'; import * as fs from 'fs/promises'; import * as path from 'path'; import { ensureDir } from './file-operations'; +import { toPosixPath } from './path-resolver'; export async function copyDirectory( sourceDir: string, @@ -10,11 +11,12 @@ export async function copyDirectory( ): Promise { const includePatterns = options.include ?? ['**/*']; const excludePatterns = options.exclude ?? []; + const cwd = toPosixPath(sourceDir); const relPaths = new Set(); for (const pattern of includePatterns) { const matches = await glob(pattern, { - cwd: sourceDir, + cwd, nodir: true, ignore: excludePatterns, }); diff --git a/packages/nx-infra-plugin/src/utils/path-resolver.ts b/packages/nx-infra-plugin/src/utils/path-resolver.ts index c52834cbe7d4..8e166d2885da 100644 --- a/packages/nx-infra-plugin/src/utils/path-resolver.ts +++ b/packages/nx-infra-plugin/src/utils/path-resolver.ts @@ -1,5 +1,6 @@ import { ExecutorContext } from '@nx/devkit'; import * as path from 'path'; +import { isWindowsOS } from './common'; const ERROR_CONFIGURATIONS_NOT_FOUND = 'Project configurations not found in executor context'; const ERROR_PROJECT_NAME_NOT_FOUND = 'Project name not found in executor context'; @@ -34,3 +35,7 @@ export function resolveFromWorkspace(context: ExecutorContext, relativePath: str export function normalizeGlobPathForWindows(filePath: string): string { return filePath.replace(/\\/g, '/'); } + +export function toPosixPath(absolutePath: string): string { + return isWindowsOS() ? normalizeGlobPathForWindows(absolutePath) : absolutePath; +} From 7f6afb4dc03eb3076015521abc96d5c3df62e4bc Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Thu, 30 Apr 2026 16:38:30 +0300 Subject: [PATCH 05/27] feat(nx-infra-plugin): implement scss-assemble executor --- packages/devextreme/project.json | 15 +++ packages/nx-infra-plugin/executors.json | 5 + .../scss-assemble/executor.e2e.spec.ts | 99 ++++++++++++++++ .../src/executors/scss-assemble/executor.ts | 111 ++++++++++++++++++ .../src/executors/scss-assemble/schema.json | 16 +++ .../src/executors/scss-assemble/schema.ts | 4 + 6 files changed, 250 insertions(+) create mode 100644 packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts create mode 100644 packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts create mode 100644 packages/nx-infra-plugin/src/executors/scss-assemble/schema.json create mode 100644 packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 906af06e8941..ae47c1954eff 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1032,6 +1032,21 @@ "{projectRoot}/artifacts/npm/devextreme/**/*" ] }, + "build:npm:scss": { + "executor": "devextreme-nx-infra-plugin:scss-assemble", + "options": { + "scssPackagePath": "../devextreme-scss", + "outputDir": "./artifacts/npm/devextreme/scss" + }, + "inputs": [ + "{workspaceRoot}/packages/devextreme-scss/scss/**/*", + "{workspaceRoot}/packages/devextreme-scss/fonts/**/*", + "{workspaceRoot}/packages/devextreme-scss/icons/**/*" + ], + "outputs": [ + "{projectRoot}/artifacts/npm/devextreme/scss/**/*" + ] + }, "build:npm:root-package-json": { "executor": "devextreme-nx-infra-plugin:prepare-package-json", "options": { diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index 599f66ad9633..7c3b2919a7bf 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -104,6 +104,11 @@ "implementation": "./src/executors/npm-assemble/executor", "schema": "./src/executors/npm-assemble/schema.json", "description": "Assemble npm package from transpiled sources, bin, license, dist files and meta" + }, + "scss-assemble": { + "implementation": "./src/executors/scss-assemble/executor", + "schema": "./src/executors/scss-assemble/schema.json", + "description": "Assemble SCSS package: copy files with data-uri inlining, fonts, and icons" } } } diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts new file mode 100644 index 000000000000..6b97e8ad76aa --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts @@ -0,0 +1,99 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import executor from './executor'; +import { ScssAssembleExecutorSchema } from './schema'; +import { createTempDir, cleanupTempDir, createMockContext } from '../../utils/test-utils'; +import { readFileText, writeFileText } from '../../utils/file-operations'; + +const SVG_CONTENT = ''; +const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +const OPTIONS: ScssAssembleExecutorSchema = { + scssPackagePath: '../devextreme-scss', + outputDir: './artifacts/npm/devextreme/scss', +}; + +describe('ScssAssembleExecutor E2E', () => { + let tempDir: string; + let scssPackageDir: string; + let outputDir: string; + + beforeEach(() => { + tempDir = createTempDir('nx-scss-assemble-e2e-'); + scssPackageDir = path.join(tempDir, 'packages', 'devextreme-scss'); + outputDir = path.join( + tempDir, + 'packages', + 'test-lib', + 'artifacts', + 'npm', + 'devextreme', + 'scss', + ); + + fs.mkdirSync(path.join(scssPackageDir, 'scss'), { recursive: true }); + fs.mkdirSync(path.join(scssPackageDir, 'fonts'), { recursive: true }); + fs.mkdirSync(path.join(scssPackageDir, 'icons', 'material'), { recursive: true }); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('should copy fonts and icons preserving directory structure under widgets/', async () => { + fs.writeFileSync( + path.join(scssPackageDir, 'fonts', 'dx-font.woff'), + Buffer.from([0x00, 0x01, 0x00, 0x00]), + ); + fs.writeFileSync(path.join(scssPackageDir, 'icons', 'material', 'icon.svg'), SVG_CONTENT); + await writeFileText(path.join(scssPackageDir, 'scss', 'placeholder.scss'), '.a {}'); + + const context = createMockContext({ root: tempDir }); + const result = await executor(OPTIONS, context); + + expect(result.success).toBe(true); + expect( + fs.existsSync( + path.join(outputDir, 'widgets', 'material', 'typography', 'fonts', 'dx-font.woff'), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(outputDir, 'widgets', 'base', 'icons', 'material', 'icon.svg')), + ).toBe(true); + }); + + it('should inline data-uri references in scss files (svg url-encoded, png base64)', async () => { + fs.mkdirSync(path.join(scssPackageDir, 'icons'), { recursive: true }); + await writeFileText(path.join(scssPackageDir, 'icons', 'foo.svg'), SVG_CONTENT); + fs.writeFileSync(path.join(scssPackageDir, 'icons', 'bar.png'), PNG_BYTES); + await writeFileText( + path.join(scssPackageDir, 'scss', 'main.scss'), + ".a { background: data-uri('icons/foo.svg'); }\n.b { background: data-uri('icons/bar.png'); }\n", + ); + + const context = createMockContext({ root: tempDir }); + const result = await executor(OPTIONS, context); + + expect(result.success).toBe(true); + + const content = await readFileText(path.join(outputDir, 'main.scss')); + const expectedSvg = `url("data:image/svg+xml;charset=UTF-8,${encodeURIComponent(SVG_CONTENT)}")`; + const expectedPng = `url("data:image/png;base64,${PNG_BYTES.toString('base64')}")`; + + expect(content).toContain(expectedSvg); + expect(content).toContain(expectedPng); + }); + + it('should pass through scss files without data-uri references unchanged', async () => { + const plainContent = '.button { color: red; }\n.icon { display: inline-block; }\n'; + await writeFileText(path.join(scssPackageDir, 'scss', 'plain.scss'), plainContent); + + const context = createMockContext({ root: tempDir }); + const result = await executor(OPTIONS, context); + + expect(result.success).toBe(true); + + const content = await readFileText(path.join(outputDir, 'plain.scss')); + expect(content).toBe(plainContent); + }); +}); diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts new file mode 100644 index 000000000000..238732276597 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts @@ -0,0 +1,111 @@ +import { PromiseExecutor, logger } from '@nx/devkit'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { glob } from 'glob'; +import { ScssAssembleExecutorSchema } from './schema'; +import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; +import { logError } from '../../utils/error-handler'; +import { readFileText, writeFileText, ensureDir } from '../../utils/file-operations'; +import { copyDirectory } from '../../utils/copy-directory'; + +const DATA_URI_REGEX = /data-uri\((?:'(image\/svg\+xml;charset=UTF-8)',\s)?['"]?([^)'"]+)['"]?\)/g; + +const SCSS_EXTENSIONS = new Set(['.scss', '.css']); + +function encodeSvg(buffer: Buffer, svgEncoding?: string): string { + const encoding = svgEncoding ?? 'image/svg+xml;charset=UTF-8'; + return `"data:${encoding},${encodeURIComponent(buffer.toString())}"`; +} + +function encodeImage(buffer: Buffer, ext: string): string { + return `"data:image/${ext};base64,${buffer.toString('base64')}"`; +} + +async function inlineDataUri(content: string, scssRoot: string): Promise { + const matches = [...content.matchAll(DATA_URI_REGEX)]; + if (matches.length === 0) return content; + + const replacements = new Map(); + + await Promise.all( + matches.map(async (match) => { + const matchStr = match[0]; + if (replacements.has(matchStr)) return; + + const svgEncoding = match[1]; + const fileName = match[2]; + const filePath = path.resolve(scssRoot, fileName); + const ext = path.extname(filePath).slice(1); + const buffer = await fs.readFile(filePath); + const escapedString = + ext === 'svg' ? encodeSvg(buffer, svgEncoding) : encodeImage(buffer, ext); + replacements.set(matchStr, `url(${escapedString})`); + }), + ); + + return content.replace(DATA_URI_REGEX, (match) => replacements.get(match) ?? match); +} + +async function copyScssWithInlineDataUri( + scssPackagePath: string, + outputDir: string, +): Promise { + const scssSourceDir = path.join(scssPackagePath, 'scss'); + const cwd = toPosixPath(scssSourceDir); + const relPaths = await glob('**/*', { cwd, nodir: true }); + + await Promise.all( + relPaths.map(async (relPath) => { + const src = path.join(scssSourceDir, relPath); + const dest = path.join(outputDir, relPath); + const ext = path.extname(relPath).toLowerCase(); + + if (SCSS_EXTENSIONS.has(ext)) { + const content = await readFileText(src); + const inlined = await inlineDataUri(content, scssPackagePath); + await writeFileText(dest, inlined); + } else { + await ensureDir(path.dirname(dest)); + await fs.copyFile(src, dest); + } + }), + ); +} + +async function copyFonts(scssPackagePath: string, outputDir: string): Promise { + await copyDirectory( + path.join(scssPackagePath, 'fonts'), + path.join(outputDir, 'widgets/material/typography/fonts'), + ); +} + +async function copyIcons(scssPackagePath: string, outputDir: string): Promise { + await copyDirectory( + path.join(scssPackagePath, 'icons'), + path.join(outputDir, 'widgets/base/icons'), + ); +} + +const runExecutor: PromiseExecutor = async (options, context) => { + const projectRoot = resolveProjectPath(context); + const scssPackagePath = path.resolve(projectRoot, options.scssPackagePath); + const outputDir = path.resolve(projectRoot, options.outputDir); + + try { + await copyScssWithInlineDataUri(scssPackagePath, outputDir); + logger.verbose('Copied SCSS files with data-uri inlining'); + + await copyFonts(scssPackagePath, outputDir); + logger.verbose('Copied fonts to widgets/material/typography/fonts'); + + await copyIcons(scssPackagePath, outputDir); + logger.verbose('Copied icons to widgets/base/icons'); + + return { success: true }; + } catch (error) { + logError('ScssAssemble executor failed', error); + return { success: false }; + } +}; + +export default runExecutor; diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json new file mode 100644 index 000000000000..b203ebd60789 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/schema", + "type": "object", + "description": "Assemble SCSS package: copy files with data-uri inlining, fonts, and icons.", + "properties": { + "scssPackagePath": { + "type": "string", + "description": "Path to the devextreme-scss package directory (relative to project root)." + }, + "outputDir": { + "type": "string", + "description": "Output directory for assembled SCSS files (relative to project root)." + } + }, + "required": ["scssPackagePath", "outputDir"] +} diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts new file mode 100644 index 000000000000..196986ac5966 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts @@ -0,0 +1,4 @@ +export interface ScssAssembleExecutorSchema { + scssPackagePath: string; + outputDir: string; +} From 52ee7c9b092506b1b4d23cc3ce4456e341839a3a Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Thu, 30 Apr 2026 17:02:20 +0300 Subject: [PATCH 06/27] chore(devextreme): add build:npm composite and dist-flatten target --- packages/devextreme/project.json | 66 +++++++++++-- .../add-license-headers/executor.e2e.spec.ts | 97 +------------------ .../executors/add-license-headers/executor.ts | 1 - .../src/executors/dts-modules/executor.ts | 2 +- .../src/executors/npm-assemble/executor.ts | 2 +- .../src/utils/license-banner.ts | 6 +- 6 files changed, 66 insertions(+), 108 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index ae47c1954eff..48368dffeec9 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1238,23 +1238,73 @@ "cwd": "{projectRoot}/artifacts/npm/devextreme-dist" } }, - "build:npm": { + "build:npm:dist-flatten": { + "executor": "devextreme-nx-infra-plugin:copy-files", + "options": { + "files": [ + { + "from": "./artifacts/npm/devextreme/dist", + "to": "./artifacts/npm/devextreme-dist" + } + ] + }, + "inputs": ["{projectRoot}/artifacts/npm/devextreme/dist/**/*"], + "outputs": ["{projectRoot}/artifacts/npm/devextreme-dist/**/*"] + }, + "verify:public-modules": { "executor": "nx:run-commands", "options": { - "command": "cross-env BUILD_ESM_PACKAGE=true gulp npm", + "command": "cross-env BUILD_ESM_PACKAGE=true gulp ts-check-public-modules", "cwd": "{projectRoot}" }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "{projectRoot}/artifacts/npm/devextreme/**/*.d.ts", + "{projectRoot}/build/gulp/modules_metadata.json" + ] + }, + "build:npm": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "pnpm nx run devextreme:build:npm:dts-modules", + "pnpm nx run devextreme:build:npm:dts-bundle", + "pnpm nx run devextreme:build:npm:dist:package-json", + "pnpm nx run devextreme:build:npm:dist:meta", + "pnpm nx run devextreme:build:npm:assemble", + "pnpm nx run devextreme:build:npm:root-package-json", + "pnpm nx run devextreme:build:npm:meta", + "pnpm nx run devextreme:compress:npm-sources", + "pnpm nx run devextreme:build:npm:dist-flatten", + "pnpm nx run devextreme:verify:public-modules", + "pnpm nx run devextreme:build:npm:scss" + ], + "parallel": false + }, + "inputs": [ "{workspaceRoot}/packages/devextreme-dist/package.json", - "{projectRoot}/artifacts/transpiled/**/*", - "{projectRoot}/artifacts/transpiled-esm-npm/**/*" + "{workspaceRoot}/packages/devextreme-dist/README.md", + "{workspaceRoot}/packages/devextreme-dist/LICENSE.md", + "{workspaceRoot}/packages/devextreme-scss/scss/**/*", + "{workspaceRoot}/packages/devextreme-scss/fonts/**/*", + "{workspaceRoot}/packages/devextreme-scss/icons/**/*", + "{workspaceRoot}/README.md", + "{projectRoot}/artifacts/transpiled-esm-npm/**/*", + "{projectRoot}/artifacts/dist_ts/**/*", + "{projectRoot}/js/**/*.json", + "{projectRoot}/license/**/*", + "{projectRoot}/build/npm-bin/**/*", + "{projectRoot}/build/npm-templates/**/*", + "{projectRoot}/build/gulp/license-header.txt", + "{projectRoot}/build/gulp/modules_metadata.json", + "{projectRoot}/ts/dx.all.d.ts", + "{projectRoot}/ts/aliases.d.ts", + "{projectRoot}/webpack.config.js", + "{projectRoot}/package.json" ], "outputs": [ "{projectRoot}/artifacts/npm/devextreme", - "{projectRoot}/artifacts/npm/devextreme-dist" + "{projectRoot}/artifacts/npm/devextreme-dist", + "{projectRoot}/artifacts/ts/dx.all.d.ts" ] }, "build": { diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts index a28319b581e6..23c2b9a68d7e 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts @@ -171,56 +171,6 @@ describe('AddLicenseHeadersExecutor E2E', () => { }); }); - describe('Idempotence', () => { - it('should not add duplicate headers on multiple runs', async () => { - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './package.json', - }; - - const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); - - const result1 = await executor(options, context); - expect(result1.success).toBe(true); - - const contentAfterFirst = await readFileText(path.join(npmDir, 'index.js')); - const headerCount1 = (contentAfterFirst.match(/\/\*!/g) || []).length; - - const result2 = await executor(options, context); - expect(result2.success).toBe(true); - - const contentAfterSecond = await readFileText(path.join(npmDir, 'index.js')); - const headerCount2 = (contentAfterSecond.match(/\/\*!/g) || []).length; - - expect(headerCount1).toBe(1); - expect(headerCount2).toBe(1); - expect(contentAfterFirst).toBe(contentAfterSecond); - }); - - it('should skip files that already have license headers', async () => { - const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); - - await writeFileText( - path.join(npmDir, 'with-header.js'), - `/*!\n * Existing header\n */\nexport const foo = 'bar';\n`, - ); - - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './package.json', - }; - - const result = await executor(options, context); - expect(result.success).toBe(true); - - const content = await readFileText(path.join(npmDir, 'with-header.js')); - - expect(content).toMatch(/^\/\*!/); - expect(content).toContain('Existing header'); - expect(content).not.toContain('test-package'); - }); - }); - describe('Error handling', () => { it('should fail gracefully with missing package.json', async () => { const options: AddLicenseHeadersExecutorSchema = { @@ -364,37 +314,6 @@ export const value = 42; expect(content).not.toMatch(/^\/\*!/); }); - it('should skip files already stamped with /** when commentType is *', async () => { - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const npmDir = path.join(projectDir, 'npm'); - await setupLicenseHeaderTemplate(); - - await writeFileText( - path.join(npmDir, 'pre-stamped.js'), - `/**\n * Already stamped\n */\nexport const x = 1;\n`, - ); - - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './package.json', - licenseTemplateFile: './build/gulp/license-header.txt', - eulaUrl: 'https://js.devexpress.com/Licensing/', - includePatterns: ['**/*.js'], - commentType: '*', - }; - - await executor(options, context); - - const contentAfterFirst = await readFileText(path.join(npmDir, 'pre-stamped.js')); - - await executor(options, context); - - const contentAfterSecond = await readFileText(path.join(npmDir, 'pre-stamped.js')); - - expect(contentAfterFirst).toBe(contentAfterSecond); - expect((contentAfterFirst.match(/\/\*\*/g) || []).length).toBe(1); - }); - it('should default to ! when commentType is not specified', async () => { const projectDir = path.join(tempDir, 'packages', 'test-lib'); const npmDir = path.join(projectDir, 'npm'); @@ -428,17 +347,11 @@ export const value = 42; includePatterns: ['**/*.js'], }; - const result1 = await executor(options, context); - expect(result1.success).toBe(true); - - const contentAfterFirst = await readFileText(path.join(npmDir, 'index.js')); - expect(contentAfterFirst).toMatch(/^\/\*\*/); - expect(contentAfterFirst).not.toMatch(/^\/\*!/); - - const result2 = await executor(options, context); - expect(result2.success).toBe(true); + const result = await executor(options, context); + expect(result.success).toBe(true); - const contentAfterSecond = await readFileText(path.join(npmDir, 'index.js')); - expect(contentAfterSecond).toBe(contentAfterFirst); + const content = await readFileText(path.join(npmDir, 'index.js')); + expect(content).toMatch(/^\/\*\*/); + expect(content).not.toMatch(/^\/\*!/); }); }); diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts index 3afe40d660d9..b66f9c9b7f03 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts @@ -90,7 +90,6 @@ const runExecutor: PromiseExecutor = async (opt const fileRelative = path.relative(targetDirectory, file).replace(/\\/g, '/'); const banner = renderBanner(fileRelative); await applyLicenseBannerToFile(file, banner, { - commentType, separator, prependAfterLicense, }); diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts index dd8c5fea5f6e..01145fa9f9d6 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts @@ -41,7 +41,7 @@ const runExecutor: PromiseExecutor = async (options, c const cwd = toPosixPath(outputDir); const dtsFiles = await glob('**/*.d.ts', { cwd, nodir: true, absolute: true }); - const jsFiles = await glob('bundles/*.js', { cwd, nodir: true, absolute: true }); + const jsFiles = await glob('bundles/dx.all.js', { cwd, nodir: true, absolute: true }); const renderBanner = await buildLicenseBannerRenderer({ ...bannerBase, commentType: '*' }); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts index 382a2ed5dcda..778af7cf5d22 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts @@ -133,7 +133,7 @@ async function applyHeadersToSourceJs( jsFiles.map(async (filePath) => { const fileRelative = path.relative(outputDir, filePath).replace(/\\/g, '/'); const banner = renderBanner(fileRelative); - await applyLicenseBannerToFile(filePath, banner, { commentType: '*' }); + await applyLicenseBannerToFile(filePath, banner); }), ); } diff --git a/packages/nx-infra-plugin/src/utils/license-banner.ts b/packages/nx-infra-plugin/src/utils/license-banner.ts index 7e491bc6175e..21e552d2f50d 100644 --- a/packages/nx-infra-plugin/src/utils/license-banner.ts +++ b/packages/nx-infra-plugin/src/utils/license-banner.ts @@ -110,15 +110,11 @@ export async function applyLicenseBannerToFile( filePath: string, banner: string, options: { - commentType: '!' | '*'; separator?: string; prependAfterLicense?: string; - }, + } = {}, ): Promise { const content = await readFileText(filePath); - if (content.startsWith(COMMENT_OPEN + options.commentType)) { - return; - } const separator = options.separator ?? ''; const prepend = options.prependAfterLicense ?? ''; await writeFileText(filePath, banner + separator + prepend + content); From 6c1867ba3161fc842daa15a560842e2070ed7806 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Thu, 30 Apr 2026 17:06:47 +0300 Subject: [PATCH 07/27] chore(devextreme): delegate gulp npm task to nx and delete npm.js --- packages/devextreme/build/gulp/npm.js | 215 -------------------------- packages/devextreme/gulpfile.js | 7 +- packages/devextreme/project.json | 19 ++- 3 files changed, 24 insertions(+), 217 deletions(-) delete mode 100644 packages/devextreme/build/gulp/npm.js diff --git a/packages/devextreme/build/gulp/npm.js b/packages/devextreme/build/gulp/npm.js deleted file mode 100644 index fad07d2b4b24..000000000000 --- a/packages/devextreme/build/gulp/npm.js +++ /dev/null @@ -1,215 +0,0 @@ -'use strict'; - -require('./ts'); - -const eol = require('gulp-eol'); -const gulp = require('gulp'); -const gulpIf = require('gulp-if'); -const merge = require('merge-stream'); -const through = require('through2'); -const replace = require('gulp-replace'); -const lazyPipe = require('lazypipe'); -const gulpFilter = require('gulp-filter'); -const gulpRename = require('gulp-rename'); -const shell = require('gulp-shell'); - -const ctx = require('./context.js'); -const env = require('./env-variables.js'); -const dataUri = require('./gulp-data-uri').gulpPipe; -const headerPipes = require('./header-pipes.js'); -const { packageDir, packageDistDir, isEsmPackage, stringSrc } = require('./utils'); - -const resultPath = ctx.RESULT_NPM_PATH; -const devextremeDistWorkspacePackageJsonPath = '../devextreme-dist/package.json'; - -const srcGlobsPattern = (path, exclude) => [ - `${path}/**/*.js`, - `!${exclude}/**/*.*`, - `!${path}/bundles/*.js`, - `!${path}/cjs/bundles/**/*`, - `!${path}/esm/bundles/**/*`, - `!${path}/bundles/modules/parts/*.js`, - `!${path}/viz/vector_map.utils/*.js`, - `!${path}/viz/docs/*.js` -]; - -const esmPackageJsonGlobs = [ - `${ctx.TRANSPILED_PROD_ESM_PATH}/**/*.json`, - `!${ctx.TRANSPILED_PROD_ESM_PATH}/viz/vector_map.utils/**/*` -]; - -const esmSrcGlobs = srcGlobsPattern( - ctx.TRANSPILED_PROD_ESM_PATH, - ctx.TRANSPILED_PROD_RENOVATION_PATH -); - -const distGlobsPattern = (jsFolder, exclude) => [ - 'artifacts/**/*.*', - '!artifacts/transpiled**/**/*', - '!artifacts/npm/**/*.*', - '!artifacts/ts/jquery*', - '!artifacts/ts/knockout*', - '!artifacts/ts/globalize*', - '!artifacts/ts/cldr*', - '!artifacts/css/dx-diagram.*', - '!artifacts/css/dx-gantt.*', - `!${jsFolder}/knockout*`, - `!${jsFolder}/cldr/*.*`, - `!${jsFolder}/cldr*`, - `!${jsFolder}/globalize/*.*`, - `!${jsFolder}/globalize*`, - `!${jsFolder}/dx-exceljs-fork*`, - `!${jsFolder}/file-saver*`, - `!${jsFolder}/jquery*`, - `!${jsFolder}/jspdf*`, - `!${jsFolder}/jspdf-autotable*`, - `!${jsFolder}/jszip*`, - `!${jsFolder}/dx.custom*`, - `!${jsFolder}/dx.viz*`, - `!${jsFolder}/dx.web*`, - `!${jsFolder}/dx-diagram*`, - `!${jsFolder}/dx-gantt*`, - `!${jsFolder}/dx-quill*`, -]; - -const srcGlobs = esmSrcGlobs; -const distGlobs = distGlobsPattern(ctx.RESULT_JS_PATH); - -const jsonGlobs = ['js/**/*.json', '!js/viz/vector_map.utils/*.*']; - -const overwriteInternalPackageName = lazyPipe() - .pipe(() => replace(/"devextreme(-.*)?"/, '"devextreme$1-internal"')); - -const licenseValidator = env.BUILD_INTERNAL_PACKAGE || env.BUILD_TEST_INTERNAL_PACKAGE ? - lazyPipe() - .pipe(() => gulpFilter(['**', '!**/license/license_validation.js'])) - .pipe(() => gulpRename(path => { - if(path.basename.includes('license_validation_internal')) { - path.basename = 'license_validation'; - } - })) : - lazyPipe() - .pipe(() => gulpFilter(['**', '!**/license/license_validation_internal.js'])); - -const sources = (src, dist, distGlob) => (() => merge( - gulp - .src(src) - .pipe(licenseValidator()) - .pipe(headerPipes.starLicense()) - .pipe(gulp.dest(dist)), - - gulp - .src(esmPackageJsonGlobs) - .pipe(gulpIf(isEsmPackage, gulp.dest(dist))), - - gulp - .src(jsonGlobs) - .pipe(gulp.dest(dist)), - - gulp - .src('build/npm-bin/*.js') - .pipe(eol('\n')) - .pipe(gulp.dest(`${dist}/bin`)), - - gulp - .src(['license/**']) - .pipe(eol('\n')) - .pipe(gulp.dest(`${dist}/license`)), - - gulp - .src('webpack.config.js') - .pipe(gulp.dest(`${dist}/bin`)), - - gulp - .src('package.json') - .pipe( - through.obj((file, enc, callback) => { - const pkg = JSON.parse(file.contents.toString(enc)); - - pkg.name = 'devextreme'; - pkg.version = ctx.version; - - delete pkg.devDependencies; - delete pkg.publishConfig; - delete pkg.scripts; - - file.contents = Buffer.from(JSON.stringify(pkg, null, 2)); - callback(null, file); - }) - ) - .pipe(gulpIf(env.BUILD_INTERNAL_PACKAGE, overwriteInternalPackageName())) - .pipe(gulp.dest(dist)), - - gulp - .src(distGlob) - .pipe(gulp.dest(`${dist}/dist`)), - - gulp - .src('../../README.md') - .pipe(gulp.dest(dist)), - - stringSrc('.npmignore', 'dist/js\ndist/ts\n!dist/css\n!/scss/bundles/*.scss\nproject.json') - .pipe(gulp.dest(`${dist}/`)) -)); - -const packagePath = `${resultPath}/${packageDir}`; -const distPath = `${resultPath}/${packageDistDir}`; - -gulp.task('npm-sources', gulp.series( - 'ts-sources', - () => gulp - .src(devextremeDistWorkspacePackageJsonPath) - .pipe( - through.obj((file, enc, callback) => { - const pkg = JSON.parse(file.contents.toString(enc)); - - pkg.version = ctx.version; - delete pkg.publishConfig; - - file.contents = Buffer.from(JSON.stringify(pkg, null, 2)); - callback(null, file); - }) - ) - .pipe(gulpIf(env.BUILD_INTERNAL_PACKAGE, overwriteInternalPackageName())) - .pipe(gulp.dest(distPath)), - () => merge( - gulp - .src('../devextreme-dist/README.md') - .pipe(gulp.dest(distPath)), - gulp - .src('../devextreme-dist/LICENSE.md') - .pipe(gulp.dest(distPath)), - ), - sources(srcGlobs, packagePath, distGlobs), - shell.task( - ctx.uglify - ? 'pnpm nx run devextreme:compress:npm-sources -c production' - : 'pnpm nx run devextreme:compress:npm-sources' - )) -); - -gulp.task('npm-dist', () => gulp - .src(`${packagePath}/dist/**/*`) - .pipe(gulp.dest(distPath)) -); - -const scssDir = `${packagePath}/scss`; - -gulp.task('npm-sass', gulp.series( - gulp.parallel( - () => gulp - .src(`${ctx.SCSS_PACKAGE_PATH}/scss/**/*`) - .pipe(dataUri()) - .pipe(gulp.dest(scssDir)), - - () => gulp - .src(`${ctx.SCSS_PACKAGE_PATH}/fonts/**/*`) - .pipe(gulp.dest(`${scssDir}/widgets/material/typography/fonts`)), - - () => gulp - .src(`${ctx.SCSS_PACKAGE_PATH}/icons/**/*`) - .pipe(gulp.dest(`${scssDir}/widgets/base/icons`)), - ) -)); - -gulp.task('npm', gulp.series('npm-sources', 'npm-dist', 'ts-check-public-modules', 'npm-sass')); diff --git a/packages/devextreme/gulpfile.js b/packages/devextreme/gulpfile.js index 984131a46c4b..fd1c9c1a6c71 100644 --- a/packages/devextreme/gulpfile.js +++ b/packages/devextreme/gulpfile.js @@ -31,7 +31,6 @@ gulp.task('clean', function(callback) { require('./build/gulp/bundler-config'); require('./build/gulp/transpile'); require('./build/gulp/js-bundles'); -require('./build/gulp/npm'); require('./build/gulp/ts'); require('./build/gulp/localization'); require('./build/gulp/check_licenses'); @@ -72,6 +71,12 @@ gulp.task('aspnet', shell.task( gulp.task('vendor', shell.task('pnpm nx run devextreme:copy:vendor')); +gulp.task('npm', shell.task( + context.uglify + ? 'pnpm nx run devextreme:build:npm -c production' + : 'pnpm nx run devextreme:build:npm' +)); + if(env.TEST_CI) { console.warn('Using test CI mode!'); } diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 48368dffeec9..bc97b09b0427 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1305,7 +1305,24 @@ "{projectRoot}/artifacts/npm/devextreme", "{projectRoot}/artifacts/npm/devextreme-dist", "{projectRoot}/artifacts/ts/dx.all.d.ts" - ] + ], + "configurations": { + "production": { + "commands": [ + "pnpm nx run devextreme:build:npm:dts-modules", + "pnpm nx run devextreme:build:npm:dts-bundle", + "pnpm nx run devextreme:build:npm:dist:package-json", + "pnpm nx run devextreme:build:npm:dist:meta", + "pnpm nx run devextreme:build:npm:assemble", + "pnpm nx run devextreme:build:npm:root-package-json", + "pnpm nx run devextreme:build:npm:meta", + "pnpm nx run devextreme:compress:npm-sources -c production", + "pnpm nx run devextreme:build:npm:dist-flatten", + "pnpm nx run devextreme:verify:public-modules", + "pnpm nx run devextreme:build:npm:scss" + ] + } + } }, "build": { "executor": "nx:run-commands", From 91e5cb561f817801b0a931be11368c434efee3ad Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 4 May 2026 12:37:07 +0300 Subject: [PATCH 08/27] chore: rename templatesDir option and derive .js banner files from templatesDir --- packages/devextreme/project.json | 2 +- .../executors/dts-modules/executor.e2e.spec.ts | 18 +++++++++--------- .../src/executors/dts-modules/executor.ts | 11 +++++++---- .../src/executors/dts-modules/schema.json | 6 +++--- .../src/executors/dts-modules/schema.ts | 2 +- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index bc97b09b0427..ee2399eabf14 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -935,7 +935,7 @@ "options": { "sourceDir": "./js", "outputDir": "./artifacts/npm/devextreme", - "legacyTemplatesDir": "./build/npm-templates", + "templatesDir": "./build/npm-templates", "licenseTemplateFile": "./build/gulp/license-header.txt", "eulaUrl": "https://js.devexpress.com/Licensing/" }, diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts index 6e69e56f2cb6..e6112f69e0e0 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts @@ -21,13 +21,13 @@ export declare function debugHelper(): void; //#ENDDEBUG `; -const LEGACY_HOVER = `import DevExpress from '../bundles/dx.all';`; -const LEGACY_DX_ALL_JS = `// This file is required to compile devextreme-angular`; +const HOVER_TEMPLATE = `import DevExpress from '../bundles/dx.all';`; +const DX_ALL_JS_TEMPLATE = `// This file is required to compile devextreme-angular`; const OPTIONS: DtsModulesExecutorSchema = { sourceDir: './js', outputDir: './artifacts/npm/devextreme', - legacyTemplatesDir: './build/npm-templates', + templatesDir: './build/npm-templates', licenseTemplateFile: './build/gulp/license-header.txt', eulaUrl: 'https://js.devexpress.com/Licensing/', }; @@ -68,11 +68,11 @@ describe('DtsModulesExecutor E2E', () => { await writeFileText( path.join(projectDir, 'build', 'npm-templates', 'events', 'hover.d.ts'), - LEGACY_HOVER, + HOVER_TEMPLATE, ); await writeFileText( path.join(projectDir, 'build', 'npm-templates', 'bundles', 'dx.all.js'), - LEGACY_DX_ALL_JS, + DX_ALL_JS_TEMPLATE, ); await writeFileText( path.join(projectDir, 'build', 'npm-templates', 'integration', 'jquery.d.ts'), @@ -84,7 +84,7 @@ describe('DtsModulesExecutor E2E', () => { cleanupTempDir(tempDir); }); - it('should produce the expected file tree (real .d.ts + legacy templates) with star-license banners and stripped debug blocks', async () => { + it('should produce the expected file tree (real .d.ts + templates) with star-license banners and stripped debug blocks', async () => { const result = await executor(OPTIONS, context); expect(result.success).toBe(true); @@ -104,7 +104,7 @@ describe('DtsModulesExecutor E2E', () => { const hoverContent = await readFileText(path.join(outDir, 'events', 'hover.d.ts')); expect(hoverContent).toMatch(/^\/\*\*/); expect(hoverContent).toContain('DevExtreme (events/hover.d.ts)'); - expect(hoverContent).toContain(LEGACY_HOVER); + expect(hoverContent).toContain(HOVER_TEMPLATE); const jqContent = await readFileText(path.join(outDir, 'integration', 'jquery.d.ts')); expect(jqContent).toMatch(/^\/\*\*/); @@ -113,12 +113,12 @@ describe('DtsModulesExecutor E2E', () => { expect(dxAllJsContent).toMatch(/^\/\*\*/); expect(dxAllJsContent).toContain('DevExtreme (dx.all.js)'); expect(dxAllJsContent).not.toContain('DevExtreme (bundles/dx.all.js)'); - expect(dxAllJsContent).toContain(LEGACY_DX_ALL_JS); + expect(dxAllJsContent).toContain(DX_ALL_JS_TEMPLATE); expect(fs.existsSync(path.join(outDir, 'bundles', 'dx.all.d.ts'))).toBe(false); }); - it('should overwrite legacy template when a real source d.ts exists at the same relative path', async () => { + it('should overwrite a template when a real source d.ts exists at the same relative path', async () => { const REAL_CONTENT = 'export declare function click(): void;'; await writeFileText(path.join(projectDir, 'js', 'events', 'click.d.ts'), REAL_CONTENT); await writeFileText( diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts index 01145fa9f9d6..81d24ece260d 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts @@ -19,12 +19,12 @@ const runExecutor: PromiseExecutor = async (options, c const projectRoot = resolveProjectPath(context); const sourceDir = path.resolve(projectRoot, options.sourceDir); const outputDir = path.resolve(projectRoot, options.outputDir); - const legacyTemplatesDir = path.resolve(projectRoot, options.legacyTemplatesDir); + const templatesDir = path.resolve(projectRoot, options.templatesDir); const licenseTemplatePath = path.resolve(projectRoot, options.licenseTemplateFile); try { - await copyDirectory(legacyTemplatesDir, outputDir); - logger.verbose(`Copied legacy templates from ${options.legacyTemplatesDir}`); + await copyDirectory(templatesDir, outputDir); + logger.verbose(`Copied templates from ${options.templatesDir}`); await copyDirectory(sourceDir, outputDir, { include: ['**/*.d.ts'] }); logger.verbose(`Copied .d.ts files from ${options.sourceDir} to ${options.outputDir}`); @@ -41,7 +41,10 @@ const runExecutor: PromiseExecutor = async (options, c const cwd = toPosixPath(outputDir); const dtsFiles = await glob('**/*.d.ts', { cwd, nodir: true, absolute: true }); - const jsFiles = await glob('bundles/dx.all.js', { cwd, nodir: true, absolute: true }); + + const templatesCwd = toPosixPath(templatesDir); + const templateJsRelPaths = await glob('**/*.js', { cwd: templatesCwd, nodir: true }); + const jsFiles = templateJsRelPaths.map((rel) => path.resolve(outputDir, rel)); const renderBanner = await buildLicenseBannerRenderer({ ...bannerBase, commentType: '*' }); diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/schema.json b/packages/nx-infra-plugin/src/executors/dts-modules/schema.json index 018a58753d4e..e60d524b1cc8 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/schema.json +++ b/packages/nx-infra-plugin/src/executors/dts-modules/schema.json @@ -11,9 +11,9 @@ "type": "string", "description": "Output directory for assembled modules (relative to project root)." }, - "legacyTemplatesDir": { + "templatesDir": { "type": "string", - "description": "Directory containing legacy template files to overlay on the output (relative to project root)." + "description": "Directory containing static template files to overlay on the output (relative to project root)." }, "licenseTemplateFile": { "type": "string", @@ -24,5 +24,5 @@ "description": "EULA URL embedded in the license header." } }, - "required": ["sourceDir", "outputDir", "legacyTemplatesDir", "licenseTemplateFile", "eulaUrl"] + "required": ["sourceDir", "outputDir", "templatesDir", "licenseTemplateFile", "eulaUrl"] } diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts b/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts index d05ea69236b0..afdfcfaf4bd2 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts @@ -1,7 +1,7 @@ export interface DtsModulesExecutorSchema { sourceDir: string; outputDir: string; - legacyTemplatesDir: string; + templatesDir: string; licenseTemplateFile: string; eulaUrl: string; } From ffc0e9030d7cdb943d65df5444f161efc9d5f2d5 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 4 May 2026 12:39:39 +0300 Subject: [PATCH 09/27] chore(nx-infra-plugin): centralize licensing defaults via license-defaults module --- packages/devextreme/project.json | 20 +--- .../add-license-headers/executor.e2e.spec.ts | 13 +-- .../executors/add-license-headers/executor.ts | 10 +- .../src/executors/dts-bundle/executor.ts | 12 ++- .../src/executors/dts-bundle/schema.json | 2 +- .../src/executors/dts-bundle/schema.ts | 4 +- .../src/executors/dts-modules/executor.ts | 12 ++- .../src/executors/dts-modules/schema.json | 2 +- .../src/executors/dts-modules/schema.ts | 4 +- .../src/executors/npm-assemble/executor.ts | 13 ++- .../src/executors/npm-assemble/schema.json | 4 +- .../src/executors/npm-assemble/schema.ts | 4 +- .../nx-infra-plugin/src/license-defaults.ts | 2 + .../src/utils/license-banner.e2e.spec.ts | 36 ++++--- .../src/utils/license-banner.ts | 93 +++---------------- 15 files changed, 95 insertions(+), 136 deletions(-) create mode 100644 packages/nx-infra-plugin/src/license-defaults.ts diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index ee2399eabf14..7f5c37fd61b1 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -48,8 +48,6 @@ "executor": "devextreme-nx-infra-plugin:add-license-headers", "options": { "targetDirectory": "./artifacts/js/localization", - "licenseTemplateFile": "./build/gulp/license-header.txt", - "eulaUrl": "https://js.devexpress.com/Licensing/", "prependAfterLicense": "\"use strict\";\n\n", "separatorBetweenBannerAndContent": "", "includePatterns": [ @@ -576,8 +574,6 @@ "executor": "devextreme-nx-infra-plugin:add-license-headers", "options": { "targetDirectory": "./artifacts/js", - "licenseTemplateFile": "./build/gulp/license-header.txt", - "eulaUrl": "https://js.devexpress.com/Licensing/", "prependAfterLicense": "\"use strict\";\n\n", "separatorBetweenBannerAndContent": "", "includePatterns": [ @@ -770,8 +766,6 @@ "executor": "devextreme-nx-infra-plugin:add-license-headers", "options": { "targetDirectory": "./artifacts/js/vectormap-utils", - "licenseTemplateFile": "./build/gulp/license-header.txt", - "eulaUrl": "https://js.devexpress.com/Licensing/", "separatorBetweenBannerAndContent": "", "prependAfterLicense": "\"use strict\";\n\n" }, @@ -870,8 +864,6 @@ "executor": "devextreme-nx-infra-plugin:add-license-headers", "options": { "targetDirectory": "./artifacts/js", - "licenseTemplateFile": "./build/gulp/license-header.txt", - "eulaUrl": "https://js.devexpress.com/Licensing/", "includePatterns": ["dx.aspnet.mvc.js"], "separatorBetweenBannerAndContent": "" }, @@ -935,9 +927,7 @@ "options": { "sourceDir": "./js", "outputDir": "./artifacts/npm/devextreme", - "templatesDir": "./build/npm-templates", - "licenseTemplateFile": "./build/gulp/license-header.txt", - "eulaUrl": "https://js.devexpress.com/Licensing/" + "templatesDir": "./build/npm-templates" }, "inputs": [ "{projectRoot}/js/**/*.d.ts", @@ -954,9 +944,7 @@ "options": { "bundleSources": ["./ts/dx.all.d.ts", "./ts/aliases.d.ts"], "artifactPath": "./artifacts/ts/dx.all.d.ts", - "packagePath": "./artifacts/npm/devextreme/bundles/dx.all.d.ts", - "licenseTemplateFile": "./build/gulp/license-header.txt", - "eulaUrl": "https://js.devexpress.com/Licensing/" + "packagePath": "./artifacts/npm/devextreme/bundles/dx.all.d.ts" }, "inputs": [ "{projectRoot}/ts/dx.all.d.ts", @@ -1013,9 +1001,7 @@ "npmBinDir": "./build/npm-bin", "webpackConfig": "./webpack.config.js", "artifactsDir": "./artifacts", - "outputDir": "./artifacts/npm/devextreme", - "licenseTemplateFile": "./build/gulp/license-header.txt", - "eulaUrl": "https://js.devexpress.com/Licensing/" + "outputDir": "./artifacts/npm/devextreme" }, "inputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/**/*", diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts index 23c2b9a68d7e..742c8fc94635 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts @@ -34,6 +34,8 @@ describe('AddLicenseHeadersExecutor E2E', () => { const projectDir = path.join(tempDir, 'packages', 'test-lib'); const npmDir = path.join(projectDir, 'npm'); + await setupLicenseHeaderTemplate(); + fs.mkdirSync(npmDir, { recursive: true }); await writeJson(path.join(projectDir, 'package.json'), { @@ -80,10 +82,9 @@ describe('AddLicenseHeadersExecutor E2E', () => { const indexContent = await readFileText(path.join(npmDir, 'index.js')); expect(indexContent).toMatch(/^\/\*!/); - expect(indexContent).toContain('test-package'); + expect(indexContent).toContain('DevExtreme (index.js)'); expect(indexContent).toContain('Version: 1.0.0'); expect(indexContent).toContain('Developer Express Inc.'); - expect(indexContent).toContain('MIT license'); const currentYear = new Date().getFullYear(); expect(indexContent).toContain(`2012 - ${currentYear}`); expect(indexContent).toMatch(/Build date:/); @@ -109,7 +110,7 @@ describe('AddLicenseHeadersExecutor E2E', () => { const buttonContent = await readFileText(path.join(npmDir, 'components', 'button.js')); expect(buttonContent).toMatch(/^\/\*!/); - expect(buttonContent).toContain('test-package'); + expect(buttonContent).toContain('components/button.js'); }); it('should preserve original file content after header', async () => { @@ -233,7 +234,7 @@ describe('AddLicenseHeadersExecutor E2E', () => { const content = await readFileText(path.join(customDir, 'custom.js')); expect(content).toMatch(/^\/\*!/); - expect(content).toContain('test-package'); + expect(content).toContain('custom.js'); }); it('should work with custom package.json path', async () => { @@ -257,7 +258,6 @@ describe('AddLicenseHeadersExecutor E2E', () => { const npmDir = path.join(projectDir, 'npm'); const content = await readFileText(path.join(npmDir, 'index.js')); - expect(content).toContain('custom-package-name'); expect(content).toContain('Version: 2.0.0'); }); }); @@ -336,7 +336,7 @@ export const value = 42; expect(content).not.toMatch(/^\/\*\*/); }); - it('should use commentType in default banner when no licenseTemplateFile is provided', async () => { + it('should fall back to DEFAULT_LICENSE_TEMPLATE_FILE when licenseTemplateFile is omitted', async () => { const projectDir = path.join(tempDir, 'packages', 'test-lib'); const npmDir = path.join(projectDir, 'npm'); @@ -353,5 +353,6 @@ export const value = 42; const content = await readFileText(path.join(npmDir, 'index.js')); expect(content).toMatch(/^\/\*\*/); expect(content).not.toMatch(/^\/\*!/); + expect(content).toContain('DevExtreme'); }); }); diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts index b66f9c9b7f03..2f7d13f28a75 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts @@ -6,6 +6,7 @@ import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; import { logError } from '../../utils/error-handler'; import { readJson } from '../../utils/file-operations'; import { buildLicenseBannerRenderer, applyLicenseBannerToFile } from '../../utils/license-banner'; +import { DEFAULT_LICENSE_TEMPLATE_FILE, DEFAULT_EULA_URL } from '../../license-defaults'; interface PackageJson { name: string; @@ -56,9 +57,10 @@ const runExecutor: PromiseExecutor = async (opt const separator = options.separatorBetweenBannerAndContent ?? '\n'; const prependAfterLicense = options.prependAfterLicense ?? ''; const commentType = options.commentType ?? '!'; - const templatePath = options.licenseTemplateFile - ? path.join(absoluteProjectRoot, options.licenseTemplateFile) - : undefined; + const templatePath = path.join( + absoluteProjectRoot, + options.licenseTemplateFile ?? DEFAULT_LICENSE_TEMPLATE_FILE, + ); let pkg: PackageJson; try { @@ -80,7 +82,7 @@ const runExecutor: PromiseExecutor = async (opt const renderBanner = await buildLicenseBannerRenderer({ templatePath, pkg, - eulaUrl: options.eulaUrl, + eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, version: options.version, commentType, }); diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts index 0094ddaf3e4c..cef14342b5c3 100644 --- a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts @@ -6,6 +6,7 @@ import { logError } from '../../utils/error-handler'; import { readJson, writeFileText } from '../../utils/file-operations'; import { concatFiles } from '../../utils/concat-content'; import { buildLicenseBannerRenderer } from '../../utils/license-banner'; +import { DEFAULT_LICENSE_TEMPLATE_FILE, DEFAULT_EULA_URL } from '../../license-defaults'; interface PackageJson { name: string; @@ -15,7 +16,10 @@ interface PackageJson { const runExecutor: PromiseExecutor = async (options, context) => { const projectRoot = resolveProjectPath(context); - const licenseTemplatePath = path.resolve(projectRoot, options.licenseTemplateFile); + const licenseTemplatePath = path.resolve( + projectRoot, + options.licenseTemplateFile ?? DEFAULT_LICENSE_TEMPLATE_FILE, + ); let pkg: PackageJson; try { @@ -33,7 +37,11 @@ const runExecutor: PromiseExecutor = async (options, co normalizeLineEndings: false, }); - const bannerBase = { templatePath: licenseTemplatePath, pkg, eulaUrl: options.eulaUrl }; + const bannerBase = { + templatePath: licenseTemplatePath, + pkg, + eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, + }; const [renderArtifactBanner, renderPackageBanner] = await Promise.all([ buildLicenseBannerRenderer({ ...bannerBase, commentType: '!' }), diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json index 6ce27b41b0fb..de2cf27fcd29 100644 --- a/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json @@ -25,5 +25,5 @@ "description": "EULA URL embedded in the license header." } }, - "required": ["bundleSources", "artifactPath", "packagePath", "licenseTemplateFile", "eulaUrl"] + "required": ["bundleSources", "artifactPath", "packagePath"] } diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/schema.ts b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.ts index ce6e070dbbfc..f8221b3f84a5 100644 --- a/packages/nx-infra-plugin/src/executors/dts-bundle/schema.ts +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.ts @@ -2,6 +2,6 @@ export interface DtsBundleExecutorSchema { bundleSources: string[]; artifactPath: string; packagePath: string; - licenseTemplateFile: string; - eulaUrl: string; + licenseTemplateFile?: string; + eulaUrl?: string; } diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts index 81d24ece260d..68f7b707f2d2 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts @@ -8,6 +8,7 @@ import { readJson, readFileText, writeFileText } from '../../utils/file-operatio import { copyDirectory } from '../../utils/copy-directory'; import { buildLicenseBannerRenderer } from '../../utils/license-banner'; import { stripDebug } from '../../utils/debug-strip'; +import { DEFAULT_LICENSE_TEMPLATE_FILE, DEFAULT_EULA_URL } from '../../license-defaults'; interface PackageJson { name: string; @@ -20,7 +21,10 @@ const runExecutor: PromiseExecutor = async (options, c const sourceDir = path.resolve(projectRoot, options.sourceDir); const outputDir = path.resolve(projectRoot, options.outputDir); const templatesDir = path.resolve(projectRoot, options.templatesDir); - const licenseTemplatePath = path.resolve(projectRoot, options.licenseTemplateFile); + const licenseTemplatePath = path.resolve( + projectRoot, + options.licenseTemplateFile ?? DEFAULT_LICENSE_TEMPLATE_FILE, + ); try { await copyDirectory(templatesDir, outputDir); @@ -37,7 +41,11 @@ const runExecutor: PromiseExecutor = async (options, c return { success: false }; } - const bannerBase = { templatePath: licenseTemplatePath, pkg, eulaUrl: options.eulaUrl }; + const bannerBase = { + templatePath: licenseTemplatePath, + pkg, + eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, + }; const cwd = toPosixPath(outputDir); const dtsFiles = await glob('**/*.d.ts', { cwd, nodir: true, absolute: true }); diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/schema.json b/packages/nx-infra-plugin/src/executors/dts-modules/schema.json index e60d524b1cc8..7797b44583e7 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/schema.json +++ b/packages/nx-infra-plugin/src/executors/dts-modules/schema.json @@ -24,5 +24,5 @@ "description": "EULA URL embedded in the license header." } }, - "required": ["sourceDir", "outputDir", "templatesDir", "licenseTemplateFile", "eulaUrl"] + "required": ["sourceDir", "outputDir", "templatesDir"] } diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts b/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts index afdfcfaf4bd2..6ab3163c658c 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts @@ -2,6 +2,6 @@ export interface DtsModulesExecutorSchema { sourceDir: string; outputDir: string; templatesDir: string; - licenseTemplateFile: string; - eulaUrl: string; + licenseTemplateFile?: string; + eulaUrl?: string; } diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts index 778af7cf5d22..621df9746665 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts @@ -14,6 +14,7 @@ import { } from '../../utils/file-operations'; import { copyDirectory } from '../../utils/copy-directory'; import { buildLicenseBannerRenderer, applyLicenseBannerToFile } from '../../utils/license-banner'; +import { DEFAULT_LICENSE_TEMPLATE_FILE, DEFAULT_EULA_URL } from '../../license-defaults'; interface PackageJson { name: string; @@ -147,7 +148,10 @@ const runExecutor: PromiseExecutor = async (options, const webpackConfigSrc = path.resolve(projectRoot, options.webpackConfig); const artifactsDir = path.resolve(projectRoot, options.artifactsDir); const outputDir = path.resolve(projectRoot, options.outputDir); - const licenseTemplatePath = path.resolve(projectRoot, options.licenseTemplateFile); + const licenseTemplatePath = path.resolve( + projectRoot, + options.licenseTemplateFile ?? DEFAULT_LICENSE_TEMPLATE_FILE, + ); try { const pkg = await readJson(path.join(projectRoot, 'package.json')); @@ -173,7 +177,12 @@ const runExecutor: PromiseExecutor = async (options, await copyDistFiles(artifactsDir, outputDir); logger.verbose(`Copied dist files from ${options.artifactsDir}`); - await applyHeadersToSourceJs(outputDir, licenseTemplatePath, pkg, options.eulaUrl); + await applyHeadersToSourceJs( + outputDir, + licenseTemplatePath, + pkg, + options.eulaUrl ?? DEFAULT_EULA_URL, + ); logger.verbose('Applied star-license banners to source JS files'); return { success: true }; diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json index 5883c8248869..1615bfba955d 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json @@ -47,8 +47,6 @@ "npmBinDir", "webpackConfig", "artifactsDir", - "outputDir", - "licenseTemplateFile", - "eulaUrl" + "outputDir" ] } diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts index 0084ee250e97..9ccabbf782cd 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts @@ -6,6 +6,6 @@ export interface NpmAssembleExecutorSchema { webpackConfig: string; artifactsDir: string; outputDir: string; - licenseTemplateFile: string; - eulaUrl: string; + licenseTemplateFile?: string; + eulaUrl?: string; } diff --git a/packages/nx-infra-plugin/src/license-defaults.ts b/packages/nx-infra-plugin/src/license-defaults.ts new file mode 100644 index 000000000000..8b97dc753adf --- /dev/null +++ b/packages/nx-infra-plugin/src/license-defaults.ts @@ -0,0 +1,2 @@ +export const DEFAULT_LICENSE_TEMPLATE_FILE = './build/gulp/license-header.txt'; +export const DEFAULT_EULA_URL = 'https://js.devexpress.com/Licensing/'; diff --git a/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts b/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts index 11042f627ae3..d5f3c1ec39f4 100644 --- a/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts @@ -28,18 +28,30 @@ it('buildLicenseBannerRenderer compiles template once and returns a sync rendere } }); -it('buildLicenseBannerRenderer uses default banner template when templatePath is omitted', async () => { - const pkg = { - name: 'devextreme', - version: '26.1.0', - repository: 'https://github.com/DevExpress/DevExtreme', - }; - const render = await buildLicenseBannerRenderer({ pkg, commentType: '!' }); +it('buildLicenseBannerRenderer interpolates eulaUrl and year into the rendered banner', async () => { + const tempDir = createTempDir('nx-license-renderer-eula-e2e-'); + try { + const templatePath = path.join(tempDir, 'license.txt'); + await writeFileText( + templatePath, + `/*<%= commentType %>\n* Copyright (c) 2012 - <%= year %> Developer Express Inc.\n* Read about DevExtreme licensing here: <%= eula %>\n*/\n`, + ); + const pkg = { name: 'devextreme', version: '26.1.0' }; + const render = await buildLicenseBannerRenderer({ + templatePath, + pkg, + eulaUrl: 'https://js.devexpress.com/Licensing/', + commentType: '!', + }); - const banner = render('events/hover.d.ts'); + const banner = render('events/hover.d.ts'); + const currentYear = new Date().getFullYear(); - expect(banner).toMatch(/^\/\*!/); - expect(banner).toContain('devextreme'); - expect(banner).toContain('26.1.0'); - expect(banner).toContain('Developer Express Inc.'); + expect(banner).toMatch(/^\/\*!/); + expect(banner).toContain('Developer Express Inc.'); + expect(banner).toContain(`2012 - ${currentYear}`); + expect(banner).toContain('https://js.devexpress.com/Licensing/'); + } finally { + cleanupTempDir(tempDir); + } }); diff --git a/packages/nx-infra-plugin/src/utils/license-banner.ts b/packages/nx-infra-plugin/src/utils/license-banner.ts index 21e552d2f50d..f5e5a1466d2b 100644 --- a/packages/nx-infra-plugin/src/utils/license-banner.ts +++ b/packages/nx-infra-plugin/src/utils/license-banner.ts @@ -2,64 +2,13 @@ import _ from 'lodash'; import { readFileText, writeFileText } from './file-operations'; export interface LicenseBannerOptions { - templatePath?: string; + templatePath: string; pkg: { name: string; version: string; repository?: string | { url?: string } }; eulaUrl?: string; version?: string; commentType: '!' | '*'; } -const COMMENT_OPEN = '/*'; -const COMMENT_END = ' */'; -const COMMENT_PREFIX = ' *'; -const NEWLINE = '\n'; - -const TEMPLATE_REGEX = /<%=\s*(\w+(?:\.\w+)*)\s*%>/g; - -function buildDefaultBannerTemplate(commentType: string): string { - return [ - `${COMMENT_OPEN}${commentType}`, - `${COMMENT_PREFIX} <%= pkg.name %>`, - `${COMMENT_PREFIX} Version: <%= pkg.version %>`, - `${COMMENT_PREFIX} Build date: <%= date %>`, - COMMENT_PREFIX, - `${COMMENT_PREFIX} Copyright (c) 2012 - <%= year %> Developer Express Inc. ALL RIGHTS RESERVED`, - COMMENT_PREFIX, - `${COMMENT_PREFIX} This software may be modified and distributed under the terms`, - `${COMMENT_PREFIX} of the MIT license. See the LICENSE file in the root of the project for details.`, - COMMENT_PREFIX, - `${COMMENT_PREFIX} <%= githubUrl %>`, - COMMENT_END, - '', - ].join(NEWLINE); -} - -function renderTemplate(template: string, data: unknown): string { - return template.replace(TEMPLATE_REGEX, (_match, key: string) => { - const keys = key.split('.'); - let value: unknown = data; - for (const k of keys) { - if (value && typeof value === 'object' && k in value) { - value = (value as Record)[k]; - } else { - return ''; - } - } - return String(value); - }); -} - -function extractGitHubUrl(repository: string | { url?: string } | undefined): string { - if (!repository) { - throw new Error("Missing 'repository' field in package.json"); - } - const rawUrl = typeof repository === 'string' ? repository : repository.url; - if (!rawUrl) { - throw new Error("Invalid 'repository' format in package.json"); - } - return rawUrl.replace(/^git\+/, '').replace(/\.git$/, ''); -} - export async function buildLicenseBannerRenderer( opts: LicenseBannerOptions, ): Promise<(fileRelative: string) => string> { @@ -67,35 +16,19 @@ export async function buildLicenseBannerRenderer( const resolvedVersion = opts.version ?? pkg.version; const now = new Date(); - if (templatePath) { - const templateText = await readFileText(templatePath); - const compiled = _.template(templateText); - return (fileRelative: string) => - compiled({ - commentType, - version: resolvedVersion, - eula: eulaUrl, - file: { relative: fileRelative }, - date: now.toDateString(), - year: now.getFullYear(), - pkg, - githubUrl: '', - }); - } - - const githubUrl = extractGitHubUrl(pkg.repository); - const defaultTemplate = buildDefaultBannerTemplate(commentType); - const templateData = { - pkg, - date: now.toDateString(), - year: now.getFullYear(), - githubUrl, - eula: eulaUrl, - version: resolvedVersion, - commentType, - }; + const templateText = await readFileText(templatePath); + const compiled = _.template(templateText); return (fileRelative: string) => - renderTemplate(defaultTemplate, { ...templateData, file: { relative: fileRelative } }); + compiled({ + commentType, + version: resolvedVersion, + eula: eulaUrl, + file: { relative: fileRelative }, + date: now.toDateString(), + year: now.getFullYear(), + pkg, + githubUrl: '', + }); } export async function renderLicenseBanner( From 90925fa3ffb56dc3976251b6f1308c4c26d35336 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 4 May 2026 12:51:50 +0300 Subject: [PATCH 10/27] chore(nx-infra-plugin): parallelize independent file-system operations in assembly executors --- .../src/executors/dts-bundle/executor.ts | 38 ++++++---- .../src/executors/npm-assemble/executor.ts | 69 ++++++++++--------- .../src/executors/scss-assemble/executor.ts | 14 ++-- 3 files changed, 66 insertions(+), 55 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts index cef14342b5c3..948bd63cdc85 100644 --- a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts @@ -14,6 +14,24 @@ interface PackageJson { repository?: string | { url?: string }; } +async function writeArtifactBundle( + artifactPath: string, + concatContent: string, + banner: string, +): Promise { + const content = concatContent.replace(/^declare global\s*\{([\s\S]*?)^\}/gm, '$1'); + await writeFileText(artifactPath, banner + content); +} + +async function writePackageBundle( + packagePath: string, + concatContent: string, + banner: string, +): Promise { + const content = concatContent.replace(/(interface JQuery\b[\s\S]*?\{)[\s\S]+?(\})/gm, '$1$2'); + await writeFileText(packagePath, banner + content + '\nexport default DevExpress;'); +} + const runExecutor: PromiseExecutor = async (options, context) => { const projectRoot = resolveProjectPath(context); const licenseTemplatePath = path.resolve( @@ -49,21 +67,15 @@ const runExecutor: PromiseExecutor = async (options, co ]); const artifactPath = path.resolve(projectRoot, options.artifactPath); - const artifactContent = concatContent.replace(/^declare global\s*\{([\s\S]*?)^\}/gm, '$1'); - const artifactBanner = renderArtifactBanner(path.basename(artifactPath)); - await writeFileText(artifactPath, artifactBanner + artifactContent); - logger.verbose(`Written artifact bundle: ${options.artifactPath}`); - const packagePath = path.resolve(projectRoot, options.packagePath); - const packageContent = concatContent.replace( - /(interface JQuery\b[\s\S]*?\{)[\s\S]+?(\})/gm, - '$1$2', - ); + const artifactBanner = renderArtifactBanner(path.basename(artifactPath)); const packageBanner = renderPackageBanner(path.basename(packagePath)); - await writeFileText( - packagePath, - packageBanner + packageContent + '\nexport default DevExpress;', - ); + + await Promise.all([ + writeArtifactBundle(artifactPath, concatContent, artifactBanner), + writePackageBundle(packagePath, concatContent, packageBanner), + ]); + logger.verbose(`Written artifact bundle: ${options.artifactPath}`); logger.verbose(`Written package bundle: ${options.packagePath}`); return { success: true }; diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts index 621df9746665..bf9cf700fc5d 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts @@ -1,5 +1,6 @@ import { PromiseExecutor, logger } from '@nx/devkit'; import * as path from 'path'; +import * as fs from 'fs/promises'; import { glob } from 'glob'; import { NpmAssembleExecutorSchema } from './schema'; import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; @@ -11,6 +12,7 @@ import { readJson, normalizeEol, ensureTrailingNewline, + ensureDir, } from '../../utils/file-operations'; import { copyDirectory } from '../../utils/copy-directory'; import { buildLicenseBannerRenderer, applyLicenseBannerToFile } from '../../utils/license-banner'; @@ -81,27 +83,34 @@ async function copyJsSrcJsonFiles(jsSrcDir: string, outputDir: string): Promise< }); } -async function normalizeDirectoryEol(dirPath: string, pattern: string): Promise { - const cwd = toPosixPath(dirPath); - const files = await glob(pattern, { cwd, nodir: true, absolute: true }); +async function copyLicenseFiles(licenseSrcDir: string, outputDir: string): Promise { + const licenseOutDir = path.join(outputDir, 'license'); + const cwd = toPosixPath(licenseSrcDir); + const relPaths = await glob('**/*', { cwd, nodir: true }); await Promise.all( - files.map(async (filePath) => { - const content = await readFileText(filePath); - await writeFileText(filePath, ensureTrailingNewline(normalizeEol(content))); + relPaths.map(async (rel) => { + const dest = path.join(licenseOutDir, rel); + await ensureDir(path.dirname(dest)); + await fs.copyFile(path.join(licenseSrcDir, rel), dest); + const content = await readFileText(dest); + await writeFileText(dest, ensureTrailingNewline(normalizeEol(content))); }), ); } -async function copyLicenseFiles(licenseSrcDir: string, outputDir: string): Promise { - const licenseOutDir = path.join(outputDir, 'license'); - await copyDirectory(licenseSrcDir, licenseOutDir); - await normalizeDirectoryEol(licenseOutDir, '**/*'); -} - async function copyNpmBinFiles(npmBinDir: string, outputDir: string): Promise { const binOutDir = path.join(outputDir, 'bin'); - await copyDirectory(npmBinDir, binOutDir, { include: ['*.js'] }); - await normalizeDirectoryEol(binOutDir, '*.js'); + const cwd = toPosixPath(npmBinDir); + const relPaths = await glob('*.js', { cwd, nodir: true }); + await ensureDir(binOutDir); + await Promise.all( + relPaths.map(async (rel) => { + const dest = path.join(binOutDir, rel); + await fs.copyFile(path.join(npmBinDir, rel), dest); + const content = await readFileText(dest); + await writeFileText(dest, ensureTrailingNewline(normalizeEol(content))); + }), + ); } async function copyDistFiles(artifactsDir: string, outputDir: string): Promise { @@ -156,26 +165,18 @@ const runExecutor: PromiseExecutor = async (options, try { const pkg = await readJson(path.join(projectRoot, 'package.json')); - await copySourceJs(transpiledDir, outputDir); - logger.verbose(`Copied source JS from ${options.transpiledDir}`); - - await copyEsmPackageJsonFiles(transpiledDir, outputDir); - logger.verbose(`Copied ESM package.json files from ${options.transpiledDir}`); - - await copyJsSrcJsonFiles(jsSrcDir, outputDir); - logger.verbose(`Copied js/**/*.json from ${options.jsSrcDir}`); - - await copyLicenseFiles(licenseSrcDir, outputDir); - logger.verbose(`Copied license files from ${options.licenseSrcDir}`); - - await copyNpmBinFiles(npmBinDir, outputDir); - logger.verbose(`Copied npm-bin scripts from ${options.npmBinDir}`); - - await copyFile(webpackConfigSrc, path.join(outputDir, 'bin', path.basename(webpackConfigSrc))); - logger.verbose(`Copied ${options.webpackConfig} to bin/`); - - await copyDistFiles(artifactsDir, outputDir); - logger.verbose(`Copied dist files from ${options.artifactsDir}`); + const webpackConfigDest = path.join(outputDir, 'bin', path.basename(webpackConfigSrc)); + + await Promise.all([ + copySourceJs(transpiledDir, outputDir), + copyEsmPackageJsonFiles(transpiledDir, outputDir), + copyJsSrcJsonFiles(jsSrcDir, outputDir), + copyLicenseFiles(licenseSrcDir, outputDir), + copyNpmBinFiles(npmBinDir, outputDir), + copyFile(webpackConfigSrc, webpackConfigDest), + copyDistFiles(artifactsDir, outputDir), + ]); + logger.verbose('Assembled npm package contents'); await applyHeadersToSourceJs( outputDir, diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts index 238732276597..5d170eea318e 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts @@ -92,14 +92,12 @@ const runExecutor: PromiseExecutor = async (options, const outputDir = path.resolve(projectRoot, options.outputDir); try { - await copyScssWithInlineDataUri(scssPackagePath, outputDir); - logger.verbose('Copied SCSS files with data-uri inlining'); - - await copyFonts(scssPackagePath, outputDir); - logger.verbose('Copied fonts to widgets/material/typography/fonts'); - - await copyIcons(scssPackagePath, outputDir); - logger.verbose('Copied icons to widgets/base/icons'); + await Promise.all([ + copyScssWithInlineDataUri(scssPackagePath, outputDir), + copyFonts(scssPackagePath, outputDir), + copyIcons(scssPackagePath, outputDir), + ]); + logger.verbose('Assembled SCSS package contents'); return { success: true }; } catch (error) { From a7dcd26f3fe59010a9cd172bc17ea32260ceb277 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 4 May 2026 15:04:05 +0300 Subject: [PATCH 11/27] chore(nx-infra-plugin): bundle license template with add-license-headers executor --- packages/devextreme-angular/project.json | 3 +- packages/devextreme-react/project.json | 3 +- packages/devextreme-vue/project.json | 3 +- .../devextreme/build/gulp/header-pipes.js | 2 +- packages/devextreme/project.json | 34 +- packages/nx-infra-plugin/scripts/build.ts | 28 +- .../executors/add-license-headers/defaults.ts | 10 + .../add-license-headers/executor.e2e.spec.ts | 393 +++++++++++------- .../executors/add-license-headers/executor.ts | 73 ++-- .../license-header-eula.txt | 8 + .../license-header-mit.txt | 12 + .../executors/add-license-headers/schema.json | 5 + .../executors/add-license-headers/schema.ts | 1 + .../src/executors/dts-bundle/executor.ts | 21 +- .../dts-modules/executor.e2e.spec.ts | 17 + .../src/executors/dts-modules/executor.ts | 16 +- .../src/executors/npm-assemble/executor.ts | 56 ++- .../prepare-package-json/executor.e2e.spec.ts | 80 ++-- .../prepare-package-json/executor.ts | 22 +- .../nx-infra-plugin/src/license-defaults.ts | 2 - .../src/utils/concat-content.ts | 8 +- .../nx-infra-plugin/src/utils/debug-strip.ts | 2 +- .../src/utils/license-banner.e2e.spec.ts | 128 +++--- .../src/utils/license-banner.ts | 29 +- packages/nx-infra-plugin/src/utils/types.ts | 6 + 25 files changed, 591 insertions(+), 371 deletions(-) create mode 100644 packages/nx-infra-plugin/src/executors/add-license-headers/defaults.ts create mode 100644 packages/nx-infra-plugin/src/executors/add-license-headers/license-header-eula.txt create mode 100644 packages/nx-infra-plugin/src/executors/add-license-headers/license-header-mit.txt delete mode 100644 packages/nx-infra-plugin/src/license-defaults.ts diff --git a/packages/devextreme-angular/project.json b/packages/devextreme-angular/project.json index be88343e4cea..1631897c6680 100644 --- a/packages/devextreme-angular/project.json +++ b/packages/devextreme-angular/project.json @@ -70,7 +70,8 @@ "packageJsonPath": "./package.json", "includePatterns": [ "**/*.ts" - ] + ], + "mode": "mit" } }, "build:ngc": { diff --git a/packages/devextreme-react/project.json b/packages/devextreme-react/project.json index 6946018a8926..ce856cd6c857 100644 --- a/packages/devextreme-react/project.json +++ b/packages/devextreme-react/project.json @@ -48,7 +48,8 @@ "executor": "devextreme-nx-infra-plugin:add-license-headers", "options": { "targetDirectory": "./npm", - "packageJsonPath": "./package.json" + "packageJsonPath": "./package.json", + "mode": "mit" } }, "npm:prepare-modules": { diff --git a/packages/devextreme-vue/project.json b/packages/devextreme-vue/project.json index d5a318867bdd..c1600502dc6a 100644 --- a/packages/devextreme-vue/project.json +++ b/packages/devextreme-vue/project.json @@ -48,7 +48,8 @@ "executor": "devextreme-nx-infra-plugin:add-license-headers", "options": { "targetDirectory": "./npm", - "packageJsonPath": "./package.json" + "packageJsonPath": "./package.json", + "mode": "mit" } }, "copy-files": { diff --git a/packages/devextreme/build/gulp/header-pipes.js b/packages/devextreme/build/gulp/header-pipes.js index a7b4635d185b..9b980a783e01 100644 --- a/packages/devextreme/build/gulp/header-pipes.js +++ b/packages/devextreme/build/gulp/header-pipes.js @@ -7,7 +7,7 @@ const path = require('path'); const context = require('./context.js'); -const licenseTemplate = fs.readFileSync(path.join(__dirname, './license-header.txt'), 'utf8'); +const licenseTemplate = fs.readFileSync(path.join(__dirname, 'license-header.txt'), 'utf8'); const useStrict = lazyPipe().pipe(function() { return header('"use strict";\n\n'); diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 7f5c37fd61b1..b868cb145143 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -55,8 +55,7 @@ ] }, "inputs": [ - "{projectRoot}/artifacts/js/localization/**/*.js", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/artifacts/js/localization/**/*.js" ], "outputs": [ "{projectRoot}/artifacts/js/localization" @@ -75,8 +74,7 @@ "inputs": [ "{projectRoot}/js/localization/messages/**/*.json", "{projectRoot}/build/gulp/localization-template.jst", - "{projectRoot}/build/gulp/generated_js.jst", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/build/gulp/generated_js.jst" ], "outputs": [ "{projectRoot}/artifacts/js/localization", @@ -591,8 +589,7 @@ } }, "inputs": [ - "{projectRoot}/artifacts/js/dx.*.js", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/artifacts/js/dx.*.js" ], "outputs": [ "{projectRoot}/artifacts/js/dx.*.js" @@ -622,8 +619,7 @@ "env": "BUILD_TEST_INTERNAL_PACKAGE" }, "{projectRoot}/artifacts/transpiled-renovation-npm/**/*", - "{projectRoot}/webpack.config.js", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/webpack.config.js" ], "outputs": [ "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration,custom}.debug.js" @@ -679,8 +675,7 @@ "env": "BUILD_TEST_INTERNAL_PACKAGE" }, "{projectRoot}/artifacts/transpiled-renovation-npm/**/*", - "{projectRoot}/webpack.config.js", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/webpack.config.js" ], "outputs": [ "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" @@ -770,8 +765,7 @@ "prependAfterLicense": "\"use strict\";\n\n" }, "inputs": [ - "{projectRoot}/artifacts/js/vectormap-utils/**/*", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/artifacts/js/vectormap-utils/**/*" ], "outputs": [ "{projectRoot}/artifacts/js/vectormap-utils" @@ -818,8 +812,7 @@ "{projectRoot}/js/viz/vector_map.utils/**/*", "{projectRoot}/build/vectormap-sources/**/*", "{projectRoot}/build/gulp/vectormaputils-template.jst", - "{projectRoot}/build/gulp/vectormapdata-template.jst", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/build/gulp/vectormapdata-template.jst" ], "outputs": [ "{projectRoot}/artifacts/js/vectormap-utils", @@ -868,8 +861,7 @@ "separatorBetweenBannerAndContent": "" }, "inputs": [ - "{projectRoot}/artifacts/js/dx.aspnet.mvc.js", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/artifacts/js/dx.aspnet.mvc.js" ], "outputs": ["{projectRoot}/artifacts/js/dx.aspnet.mvc.js"] }, @@ -931,8 +923,7 @@ }, "inputs": [ "{projectRoot}/js/**/*.d.ts", - "{projectRoot}/build/npm-templates/**/*", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/build/npm-templates/**/*" ], "outputs": [ "{projectRoot}/artifacts/npm/devextreme/**/*.d.ts", @@ -948,8 +939,7 @@ }, "inputs": [ "{projectRoot}/ts/dx.all.d.ts", - "{projectRoot}/ts/aliases.d.ts", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/ts/aliases.d.ts" ], "outputs": [ "{projectRoot}/artifacts/ts/dx.all.d.ts", @@ -1011,8 +1001,7 @@ "{projectRoot}/webpack.config.js", "{projectRoot}/artifacts/js/**/*", "{projectRoot}/artifacts/css/**/*", - "{projectRoot}/artifacts/ts/**/*", - "{projectRoot}/build/gulp/license-header.txt" + "{projectRoot}/artifacts/ts/**/*" ], "outputs": [ "{projectRoot}/artifacts/npm/devextreme/**/*" @@ -1280,7 +1269,6 @@ "{projectRoot}/license/**/*", "{projectRoot}/build/npm-bin/**/*", "{projectRoot}/build/npm-templates/**/*", - "{projectRoot}/build/gulp/license-header.txt", "{projectRoot}/build/gulp/modules_metadata.json", "{projectRoot}/ts/dx.all.d.ts", "{projectRoot}/ts/aliases.d.ts", diff --git a/packages/nx-infra-plugin/scripts/build.ts b/packages/nx-infra-plugin/scripts/build.ts index 762ffd7e0c97..506a0dd56f4c 100644 --- a/packages/nx-infra-plugin/scripts/build.ts +++ b/packages/nx-infra-plugin/scripts/build.ts @@ -8,6 +8,7 @@ const TEMP_TSCONFIG_NAME = 'tsconfig.bootstrap.json'; const TSCONFIG_LIB_NAME = 'tsconfig.lib.json'; const EXECUTORS_JSON_NAME = 'executors.json'; const JSON_EXTENSION = '.json'; +const TXT_EXTENSION = '.txt'; const TSCONFIG_PREFIX = 'tsconfig'; interface PathConfig { @@ -83,10 +84,11 @@ const compileTypeScript = (pluginDir: string, configPath: string): CompilationRe } }; -const isJsonAsset = (filename: string): boolean => - filename.endsWith(JSON_EXTENSION) && !filename.includes(TSCONFIG_PREFIX); +const isAssetFile = (filename: string): boolean => + (filename.endsWith(JSON_EXTENSION) && !filename.includes(TSCONFIG_PREFIX)) + || filename.endsWith(TXT_EXTENSION); -const copyJsonAssets = (srcDir: string, destDir: string): AssetCopyResult => { +const copyAssets = (srcDir: string, destDir: string): AssetCopyResult => { if (!fs.existsSync(srcDir)) { return { filesCopied: 0 }; } @@ -102,9 +104,9 @@ const copyJsonAssets = (srcDir: string, destDir: string): AssetCopyResult => { if (!fs.existsSync(destPath)) { fs.mkdirSync(destPath, { recursive: true }); } - const result = copyJsonAssets(srcPath, destPath); + const result = copyAssets(srcPath, destPath); filesCopied += result.filesCopied; - } else if (isJsonAsset(entry.name)) { + } else if (isAssetFile(entry.name)) { fs.copyFileSync(srcPath, destPath); filesCopied++; } @@ -157,11 +159,11 @@ const shouldSkipBuild = (distPath: string, forceRebuild: boolean): boolean => { const buildPlugin = (paths: PathConfig, forceRebuild = false): void => { if (shouldSkipBuild(paths.distDir, forceRebuild)) { - console.log('✓ Plugin already built, skipping...'); + console.log('[nx-infra-plugin] Already built, skipping.'); process.exit(0); } - console.log(' Compiling TypeScript...'); + console.log('[nx-infra-plugin] Compiling TypeScript...'); const tempConfigPath = path.join(paths.pluginDir, TEMP_TSCONFIG_NAME); const originalConfig = readTsConfig(paths.tsconfig); @@ -175,12 +177,12 @@ const buildPlugin = (paths: PathConfig, forceRebuild = false): void => { throw new Error(result.error); } - console.log(' Copying assets...'); - copyJsonAssets(paths.srcDir, paths.distDir); + console.log('[nx-infra-plugin] Copying assets...'); + copyAssets(paths.srcDir, paths.distDir); copyExecutorsJson(paths.pluginDir, paths.distDir); - console.log('✓ Plugin built successfully!'); + console.log('[nx-infra-plugin] Build complete.'); } finally { cleanupTempConfig(tempConfigPath); } @@ -194,15 +196,15 @@ const parseArgs = (): { forceRebuild: boolean } => { }; const main = (): void => { - console.log('🔨 Building nx-infra-plugin...'); + console.log('[nx-infra-plugin] Building...'); try { const { forceRebuild } = parseArgs(); const paths = buildPathConfig(__dirname); buildPlugin(paths, forceRebuild); } catch (error) { - console.error('⚠ Failed to build plugin:', (error as Error).message); - console.error(' The plugin will be built on first use by NX'); + console.error('[nx-infra-plugin] Build failed:', (error as Error).message); + console.error('[nx-infra-plugin] The plugin will be built on first use by NX.'); process.exit(1); } }; diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/defaults.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/defaults.ts new file mode 100644 index 000000000000..9b1514f2a758 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/defaults.ts @@ -0,0 +1,10 @@ +import * as path from 'path'; + +export const DEFAULT_LICENSE_TEMPLATE_EULA = path.resolve(__dirname, 'license-header-eula.txt'); +export const DEFAULT_LICENSE_TEMPLATE_MIT = path.resolve(__dirname, 'license-header-mit.txt'); +export const DEFAULT_EULA_URL = 'https://js.devexpress.com/Licensing/'; + +export const DEFAULT_TARGET_DIR = './npm'; +export const DEFAULT_PACKAGE_JSON = './package.json'; +export const DEFAULT_INCLUDE_PATTERNS = ['**/*.{ts,js}'] as const; +export const DEFAULT_EXCLUDE_PATTERNS = ['**/*.json', '**/*.map'] as const; diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts index 742c8fc94635..52b69a677de7 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts @@ -67,78 +67,77 @@ describe('AddLicenseHeadersExecutor E2E', () => { cleanupTempDir(tempDir); }); - describe('Basic functionality', () => { - it('should add license headers to all JS and TS files', async () => { - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './package.json', - }; + it('should add license headers to all JS and TS files', async () => { + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + }; - const result = await executor(options, context); + const result = await executor(options, context); - expect(result.success).toBe(true); + expect(result.success).toBe(true); - const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); + const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); - const indexContent = await readFileText(path.join(npmDir, 'index.js')); - expect(indexContent).toMatch(/^\/\*!/); - expect(indexContent).toContain('DevExtreme (index.js)'); - expect(indexContent).toContain('Version: 1.0.0'); - expect(indexContent).toContain('Developer Express Inc.'); - const currentYear = new Date().getFullYear(); - expect(indexContent).toContain(`2012 - ${currentYear}`); - expect(indexContent).toMatch(/Build date:/); + const indexContent = await readFileText(path.join(npmDir, 'index.js')); + expect(indexContent).toMatch(/^\/\*!/); + expect(indexContent).toContain('DevExtreme (index.js)'); + expect(indexContent).toContain('Version: 1.0.0'); + expect(indexContent).toContain('Developer Express Inc.'); + const currentYear = new Date().getFullYear(); + expect(indexContent).toContain(`2012 - ${currentYear}`); + expect(indexContent).toMatch(/Build date:/); - const utilsContent = await readFileText(path.join(npmDir, 'utils.js')); - expect(utilsContent).toMatch(/^\/\*!/); + const utilsContent = await readFileText(path.join(npmDir, 'utils.js')); + expect(utilsContent).toMatch(/^\/\*!/); - const typesContent = await readFileText(path.join(npmDir, 'types.ts')); - expect(typesContent).toMatch(/^\/\*!/); - }); + const typesContent = await readFileText(path.join(npmDir, 'types.ts')); + expect(typesContent).toMatch(/^\/\*!/); + }); - it('should add headers to nested files', async () => { - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './package.json', - }; + it('should add headers to nested files', async () => { + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + }; - const result = await executor(options, context); + const result = await executor(options, context); - expect(result.success).toBe(true); + expect(result.success).toBe(true); - const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); - const buttonContent = await readFileText(path.join(npmDir, 'components', 'button.js')); + const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); + const buttonContent = await readFileText(path.join(npmDir, 'components', 'button.js')); - expect(buttonContent).toMatch(/^\/\*!/); - expect(buttonContent).toContain('components/button.js'); - }); + expect(buttonContent).toMatch(/^\/\*!/); + expect(buttonContent).toContain('components/button.js'); + }); - it('should preserve original file content after header', async () => { - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './package.json', - }; + it('should preserve original file content after header', async () => { + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + }; - const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); - const originalContent = await readFileText(path.join(npmDir, 'index.js')); + const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); + const originalContent = await readFileText(path.join(npmDir, 'index.js')); - await executor(options, context); + await executor(options, context); - const newContent = await readFileText(path.join(npmDir, 'index.js')); + const newContent = await readFileText(path.join(npmDir, 'index.js')); - expect(newContent).toMatch(/^\/\*!/); + expect(newContent).toMatch(/^\/\*!/); - expect(newContent).toContain(originalContent.trim()); - }); + expect(newContent).toContain(originalContent.trim()); + }); - it('should support custom license template', async () => { - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const buildDir = path.join(projectDir, 'build', 'gulp'); - fs.mkdirSync(buildDir, { recursive: true }); + it('should support custom license template', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const buildDir = path.join(projectDir, 'build', 'gulp'); + fs.mkdirSync(buildDir, { recursive: true }); - await writeFileText( - path.join(buildDir, 'license-header.txt'), - `/*! + await writeFileText( + path.join(buildDir, 'license-header.txt'), + `/*! * DevExtreme (<%= file.relative %>) * Version: <%= version %> * Build date: <%= date %> @@ -147,119 +146,114 @@ describe('AddLicenseHeadersExecutor E2E', () => { * Read about DevExtreme licensing here: <%= eula %> */ `, - ); - - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './package.json', - licenseTemplateFile: './build/gulp/license-header.txt', - eulaUrl: 'https://js.devexpress.com/Licensing/', - prependAfterLicense: '"use strict";\n\n', - includePatterns: ['**/*.js'], - }; - - const result = await executor(options, context); - expect(result.success).toBe(true); - - const npmDir = path.join(projectDir, 'npm'); - const content = await readFileText(path.join(npmDir, 'index.js')); - - expect(content).toMatch(/^\/\*!/); - expect(content).toContain('DevExtreme (index.js)'); - expect(content).toContain('https://js.devexpress.com/Licensing/'); - expect(content).toContain('"use strict";'); - expect(content).toContain("return 'Hello'"); - }); + ); + + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + licenseTemplateFile: './build/gulp/license-header.txt', + eulaUrl: 'https://js.devexpress.com/Licensing/', + prependAfterLicense: '"use strict";\n\n', + includePatterns: ['**/*.js'], + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const npmDir = path.join(projectDir, 'npm'); + const content = await readFileText(path.join(npmDir, 'index.js')); + + expect(content).toMatch(/^\/\*!/); + expect(content).toContain('DevExtreme (index.js)'); + expect(content).toContain('https://js.devexpress.com/Licensing/'); + expect(content).toContain('"use strict";'); + expect(content).toContain("return 'Hello'"); }); - describe('Error handling', () => { - it('should fail gracefully with missing package.json', async () => { - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './nonexistent-package.json', - }; + it('should fail gracefully with missing package.json', async () => { + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './nonexistent-package.json', + }; - const result = await executor(options, context); + const result = await executor(options, context); - expect(result.success).toBe(false); - }); + expect(result.success).toBe(false); + }); - it('should fail gracefully with invalid package.json', async () => { - const projectDir = path.join(tempDir, 'packages', 'test-lib'); + it('should fail gracefully with invalid package.json', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); - await writeFileText(path.join(projectDir, 'package.json'), 'not valid json {{{}'); + await writeFileText(path.join(projectDir, 'package.json'), 'not valid json {{{}'); - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './package.json', - }; + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + }; - const result = await executor(options, context); + const result = await executor(options, context); - expect(result.success).toBe(false); - }); + expect(result.success).toBe(false); + }); - it('should handle empty target directory', async () => { - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const emptyDir = path.join(projectDir, 'empty'); - fs.mkdirSync(emptyDir, { recursive: true }); + it('should handle empty target directory', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const emptyDir = path.join(projectDir, 'empty'); + fs.mkdirSync(emptyDir, { recursive: true }); - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './empty', - packageJsonPath: './package.json', - }; + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './empty', + packageJsonPath: './package.json', + }; - const result = await executor(options, context); + const result = await executor(options, context); - expect(result.success).toBe(true); - }); + expect(result.success).toBe(true); }); - describe('Custom paths', () => { - it('should work with custom target directory', async () => { - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const customDir = path.join(projectDir, 'dist'); - fs.mkdirSync(customDir, { recursive: true }); + it('should work with custom target directory', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const customDir = path.join(projectDir, 'dist'); + fs.mkdirSync(customDir, { recursive: true }); - await writeFileText(path.join(customDir, 'custom.js'), `export const custom = true;\n`); + await writeFileText(path.join(customDir, 'custom.js'), `export const custom = true;\n`); - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './dist', - packageJsonPath: './package.json', - }; + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './dist', + packageJsonPath: './package.json', + }; - const result = await executor(options, context); + const result = await executor(options, context); - expect(result.success).toBe(true); + expect(result.success).toBe(true); - const content = await readFileText(path.join(customDir, 'custom.js')); - expect(content).toMatch(/^\/\*!/); - expect(content).toContain('custom.js'); - }); + const content = await readFileText(path.join(customDir, 'custom.js')); + expect(content).toMatch(/^\/\*!/); + expect(content).toContain('custom.js'); + }); - it('should work with custom package.json path', async () => { - const projectDir = path.join(tempDir, 'packages', 'test-lib'); + it('should work with custom package.json path', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); - await writeJson(path.join(projectDir, 'custom-package.json'), { - name: 'custom-package-name', - version: '2.0.0', - repository: 'https://github.com/DevExpress/custom-package', - }); + await writeJson(path.join(projectDir, 'custom-package.json'), { + name: 'custom-package-name', + version: '2.0.0', + repository: 'https://github.com/DevExpress/custom-package', + }); - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './custom-package.json', - }; + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './custom-package.json', + }; - const result = await executor(options, context); + const result = await executor(options, context); - expect(result.success).toBe(true); + expect(result.success).toBe(true); - const npmDir = path.join(projectDir, 'npm'); - const content = await readFileText(path.join(npmDir, 'index.js')); + const npmDir = path.join(projectDir, 'npm'); + const content = await readFileText(path.join(npmDir, 'index.js')); - expect(content).toContain('Version: 2.0.0'); - }); + expect(content).toContain('Version: 2.0.0'); }); it('should preserve formatting and whitespace', async () => { @@ -336,7 +330,7 @@ export const value = 42; expect(content).not.toMatch(/^\/\*\*/); }); - it('should fall back to DEFAULT_LICENSE_TEMPLATE_FILE when licenseTemplateFile is omitted', async () => { + it('should fall back to DEFAULT_LICENSE_TEMPLATE_EULA when licenseTemplateFile is omitted', async () => { const projectDir = path.join(tempDir, 'packages', 'test-lib'); const npmDir = path.join(projectDir, 'npm'); @@ -355,4 +349,123 @@ export const value = 42; expect(content).not.toMatch(/^\/\*!/); expect(content).toContain('DevExtreme'); }); + + it('should use the bundled MIT template when mode=mit and no licenseTemplateFile is provided', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + await writeJson(path.join(projectDir, 'package.json'), { + name: 'test-pkg', + version: '1.2.3', + repository: { url: 'git+https://github.com/test/repo.git' }, + }); + + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + mode: 'mit', + includePatterns: ['**/*.js'], + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const npmDir = path.join(projectDir, 'npm'); + const content = await readFileText(path.join(npmDir, 'index.js')); + const firstLine = content.split('\n')[0]; + const currentYear = new Date().getFullYear(); + + expect(firstLine).toBe('/*!'); + expect(content).toContain(' * test-pkg'); + expect(content).toContain(' * Version: 1.2.3'); + expect(content).toContain( + ` * Copyright (c) 2012 - ${currentYear} Developer Express Inc. ALL RIGHTS RESERVED`, + ); + expect(content).toContain(' * This software may be modified and distributed under the terms'); + expect(content).toContain(' * https://github.com/test/repo'); + expect(content).not.toContain('DevExtreme ('); + expect(content).not.toContain('Read about DevExtreme licensing here'); + }); + + it('should use the bundled EULA template when mode=eula and no licenseTemplateFile is provided', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + await writeJson(path.join(projectDir, 'package.json'), { + name: 'devextreme', + version: '26.1.0', + repository: { url: 'https://github.com/DevExpress/DevExtreme.git' }, + }); + + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + mode: 'eula', + includePatterns: ['**/*.js'], + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const npmDir = path.join(projectDir, 'npm'); + const content = await readFileText(path.join(npmDir, 'index.js')); + + expect(content).toContain('DevExtreme ('); + expect(content).toContain('Version: 26.1.0'); + expect(content).toContain('Read about DevExtreme licensing here:'); + expect(content).not.toContain('modified and distributed'); + }); + + it('should default to mode=eula when neither mode nor licenseTemplateFile is provided', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + await writeJson(path.join(projectDir, 'package.json'), { + name: 'devextreme', + version: '26.1.0', + repository: { url: 'https://github.com/DevExpress/DevExtreme.git' }, + }); + + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + includePatterns: ['**/*.js'], + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const npmDir = path.join(projectDir, 'npm'); + const content = await readFileText(path.join(npmDir, 'index.js')); + + expect(content).toContain('DevExtreme ('); + expect(content).toContain('Version: 26.1.0'); + expect(content).toContain('Read about DevExtreme licensing here:'); + expect(content).not.toContain('modified and distributed'); + }); + + it('should respect licenseTemplateFile precedence over mode', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const buildDir = path.join(projectDir, 'build'); + fs.mkdirSync(buildDir, { recursive: true }); + await writeFileText( + path.join(buildDir, 'custom-template.txt'), + `/*-CUSTOM-<%= pkg.name %>-CUSTOM-*/\n`, + ); + await writeJson(path.join(projectDir, 'package.json'), { + name: 'test-pkg', + version: '1.0.0', + repository: { url: 'https://github.com/test/repo.git' }, + }); + + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + licenseTemplateFile: './build/custom-template.txt', + mode: 'mit', + includePatterns: ['**/*.js'], + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const npmDir = path.join(projectDir, 'npm'); + const content = await readFileText(path.join(npmDir, 'index.js')); + const firstLine = content.split('\n')[0]; + expect(firstLine).toMatch(/^\/\*-CUSTOM-/); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts index 2f7d13f28a75..92eb65c9a955 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts @@ -5,22 +5,35 @@ import { AddLicenseHeadersExecutorSchema } from './schema'; import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; import { logError } from '../../utils/error-handler'; import { readJson } from '../../utils/file-operations'; -import { buildLicenseBannerRenderer, applyLicenseBannerToFile } from '../../utils/license-banner'; -import { DEFAULT_LICENSE_TEMPLATE_FILE, DEFAULT_EULA_URL } from '../../license-defaults'; +import { + buildLicenseBannerRenderer, + applyLicenseBannerToFile, + extractGitHubUrl, +} from '../../utils/license-banner'; +import type { PackageJson } from '../../utils/types'; +import { + DEFAULT_LICENSE_TEMPLATE_EULA, + DEFAULT_LICENSE_TEMPLATE_MIT, + DEFAULT_EULA_URL, + DEFAULT_TARGET_DIR, + DEFAULT_PACKAGE_JSON, + DEFAULT_INCLUDE_PATTERNS, + DEFAULT_EXCLUDE_PATTERNS, +} from './defaults'; -interface PackageJson { - name: string; - version: string; - repository?: string | { url?: string }; +function resolveTemplatePath( + absoluteProjectRoot: string, + options: { licenseTemplateFile?: string; mode?: 'eula' | 'mit' }, +): string { + if (options.licenseTemplateFile) { + return path.join(absoluteProjectRoot, options.licenseTemplateFile); + } + if (options.mode === 'mit') { + return DEFAULT_LICENSE_TEMPLATE_MIT; + } + return DEFAULT_LICENSE_TEMPLATE_EULA; } -const DEFAULTS = { - TARGET_DIR: './npm', - PACKAGE_JSON: './package.json', - INCLUDE_PATTERNS: ['**/*.{ts,js}'], - EXCLUDE_PATTERNS: ['**/*.json', '**/*.map'], -} as const; - interface DiscoverFilesOptions { targetDirectory: string; includePatterns: readonly string[]; @@ -29,15 +42,16 @@ interface DiscoverFilesOptions { async function discoverFiles(options: DiscoverFilesOptions): Promise { const { targetDirectory, includePatterns, excludePatterns } = options; - - const patterns = includePatterns.map((pattern) => { - const fullPath = path.join(targetDirectory, pattern); - return toPosixPath(fullPath); - }); + const cwd = toPosixPath(targetDirectory); const allFiles: string[] = []; - for (const pattern of patterns) { - const matchedFiles = await glob(pattern, { ignore: [...excludePatterns] }); + for (const pattern of includePatterns) { + const matchedFiles = await glob(pattern, { + cwd, + absolute: true, + nodir: true, + ignore: [...excludePatterns], + }); allFiles.push(...matchedFiles); } @@ -48,19 +62,16 @@ const runExecutor: PromiseExecutor = async (opt const absoluteProjectRoot = resolveProjectPath(context); const targetDirectory = path.join( absoluteProjectRoot, - options.targetDirectory ?? DEFAULTS.TARGET_DIR, + options.targetDirectory ?? DEFAULT_TARGET_DIR, ); const packageJsonPath = path.join( absoluteProjectRoot, - options.packageJsonPath ?? DEFAULTS.PACKAGE_JSON, + options.packageJsonPath ?? DEFAULT_PACKAGE_JSON, ); const separator = options.separatorBetweenBannerAndContent ?? '\n'; const prependAfterLicense = options.prependAfterLicense ?? ''; const commentType = options.commentType ?? '!'; - const templatePath = path.join( - absoluteProjectRoot, - options.licenseTemplateFile ?? DEFAULT_LICENSE_TEMPLATE_FILE, - ); + const templatePath = resolveTemplatePath(absoluteProjectRoot, options); let pkg: PackageJson; try { @@ -70,11 +81,16 @@ const runExecutor: PromiseExecutor = async (opt return { success: false }; } + const githubUrl = + templatePath === DEFAULT_LICENSE_TEMPLATE_MIT + ? extractGitHubUrl(pkg.repository, packageJsonPath) + : ''; + try { const files = await discoverFiles({ targetDirectory, - includePatterns: options.includePatterns ?? DEFAULTS.INCLUDE_PATTERNS, - excludePatterns: options.excludePatterns ?? DEFAULTS.EXCLUDE_PATTERNS, + includePatterns: options.includePatterns ?? DEFAULT_INCLUDE_PATTERNS, + excludePatterns: options.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS, }); logger.verbose(`Adding license headers to ${files.length} files...`); @@ -85,6 +101,7 @@ const runExecutor: PromiseExecutor = async (opt eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, version: options.version, commentType, + githubUrl, }); await Promise.all( diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/license-header-eula.txt b/packages/nx-infra-plugin/src/executors/add-license-headers/license-header-eula.txt new file mode 100644 index 000000000000..5a8fe2bd76cb --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/license-header-eula.txt @@ -0,0 +1,8 @@ +/*<%= commentType %> +* DevExtreme (<%= file.relative.replace(/\\/g, '/') %>) +* Version: <%= version %> +* Build date: <%= (new Date()).toDateString() %> +* +* Copyright (c) 2012 - <%= (new Date()).getFullYear() %> Developer Express Inc. ALL RIGHTS RESERVED +* Read about DevExtreme licensing here: <%= eula %> +*/ diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/license-header-mit.txt b/packages/nx-infra-plugin/src/executors/add-license-headers/license-header-mit.txt new file mode 100644 index 000000000000..2f603758a158 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/license-header-mit.txt @@ -0,0 +1,12 @@ +/*! + * <%= pkg.name %> + * Version: <%= pkg.version %> + * Build date: <%= date %> + * + * Copyright (c) 2012 - <%= year %> Developer Express Inc. ALL RIGHTS RESERVED + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file in the root of the project for details. + * + * <%= githubUrl %> + */ diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json index 8fd44ef452a0..e99b8d3c8f67 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json @@ -56,6 +56,11 @@ "description": "Comment type marker placed after /* in the license banner opening", "enum": ["!", "*"], "default": "!" + }, + "mode": { + "type": "string", + "enum": ["eula", "mit"], + "description": "Selects which bundled license template to use. 'eula' references the DevExtreme EULA URL; 'mit' references MIT terms and a GitHub repo URL. When licenseTemplateFile is provided, mode is ignored." } }, "required": [] diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts index 16e388b044b8..2ca19fed2dac 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts @@ -9,4 +9,5 @@ export interface AddLicenseHeadersExecutorSchema { prependAfterLicense?: string; version?: string; commentType?: '!' | '*'; + mode?: 'eula' | 'mit'; } diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts index 948bd63cdc85..d2f6f97a4ba5 100644 --- a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts @@ -6,20 +6,18 @@ import { logError } from '../../utils/error-handler'; import { readJson, writeFileText } from '../../utils/file-operations'; import { concatFiles } from '../../utils/concat-content'; import { buildLicenseBannerRenderer } from '../../utils/license-banner'; -import { DEFAULT_LICENSE_TEMPLATE_FILE, DEFAULT_EULA_URL } from '../../license-defaults'; +import { DEFAULT_LICENSE_TEMPLATE_EULA, DEFAULT_EULA_URL } from '../add-license-headers/defaults'; +import type { PackageJson } from '../../utils/types'; -interface PackageJson { - name: string; - version: string; - repository?: string | { url?: string }; -} +const STRIP_DECLARE_GLOBAL = /^declare global\s*\{([\s\S]*?)^\}/gm; +const STRIP_JQUERY_INTERFACE_BODY = /(interface JQuery\b[\s\S]*?\{)[\s\S]+?(\})/gm; async function writeArtifactBundle( artifactPath: string, concatContent: string, banner: string, ): Promise { - const content = concatContent.replace(/^declare global\s*\{([\s\S]*?)^\}/gm, '$1'); + const content = concatContent.replace(STRIP_DECLARE_GLOBAL, '$1'); await writeFileText(artifactPath, banner + content); } @@ -28,16 +26,15 @@ async function writePackageBundle( concatContent: string, banner: string, ): Promise { - const content = concatContent.replace(/(interface JQuery\b[\s\S]*?\{)[\s\S]+?(\})/gm, '$1$2'); + const content = concatContent.replace(STRIP_JQUERY_INTERFACE_BODY, '$1$2'); await writeFileText(packagePath, banner + content + '\nexport default DevExpress;'); } const runExecutor: PromiseExecutor = async (options, context) => { const projectRoot = resolveProjectPath(context); - const licenseTemplatePath = path.resolve( - projectRoot, - options.licenseTemplateFile ?? DEFAULT_LICENSE_TEMPLATE_FILE, - ); + const licenseTemplatePath = options.licenseTemplateFile + ? path.resolve(projectRoot, options.licenseTemplateFile) + : DEFAULT_LICENSE_TEMPLATE_EULA; let pkg: PackageJson; try { diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts index e6112f69e0e0..cfea6733b7fb 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts @@ -151,4 +151,21 @@ describe('DtsModulesExecutor E2E', () => { expect(contentAfterFirst).toBe(contentAfterSecond); expect((contentAfterFirst.match(/\/\*\*/g) ?? []).length).toBe(1); }); + + it('should use the bundled template when licenseTemplateFile is omitted', async () => { + const options: DtsModulesExecutorSchema = { + sourceDir: './js', + outputDir: './artifacts/npm/devextreme', + templatesDir: './build/npm-templates', + eulaUrl: 'https://js.devexpress.com/Licensing/', + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + const accordionContent = await readFileText(path.join(outDir, 'accordion.d.ts')); + expect(accordionContent).toMatch(/^\/\*\*/); + expect(accordionContent).toContain('DevExtreme (accordion.d.ts)'); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts index 68f7b707f2d2..ec358078c3e1 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts @@ -8,23 +8,17 @@ import { readJson, readFileText, writeFileText } from '../../utils/file-operatio import { copyDirectory } from '../../utils/copy-directory'; import { buildLicenseBannerRenderer } from '../../utils/license-banner'; import { stripDebug } from '../../utils/debug-strip'; -import { DEFAULT_LICENSE_TEMPLATE_FILE, DEFAULT_EULA_URL } from '../../license-defaults'; - -interface PackageJson { - name: string; - version: string; - repository?: string | { url?: string }; -} +import { DEFAULT_LICENSE_TEMPLATE_EULA, DEFAULT_EULA_URL } from '../add-license-headers/defaults'; +import type { PackageJson } from '../../utils/types'; const runExecutor: PromiseExecutor = async (options, context) => { const projectRoot = resolveProjectPath(context); const sourceDir = path.resolve(projectRoot, options.sourceDir); const outputDir = path.resolve(projectRoot, options.outputDir); const templatesDir = path.resolve(projectRoot, options.templatesDir); - const licenseTemplatePath = path.resolve( - projectRoot, - options.licenseTemplateFile ?? DEFAULT_LICENSE_TEMPLATE_FILE, - ); + const licenseTemplatePath = options.licenseTemplateFile + ? path.resolve(projectRoot, options.licenseTemplateFile) + : DEFAULT_LICENSE_TEMPLATE_EULA; try { await copyDirectory(templatesDir, outputDir); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts index bf9cf700fc5d..fe48a6bc6961 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts @@ -16,13 +16,8 @@ import { } from '../../utils/file-operations'; import { copyDirectory } from '../../utils/copy-directory'; import { buildLicenseBannerRenderer, applyLicenseBannerToFile } from '../../utils/license-banner'; -import { DEFAULT_LICENSE_TEMPLATE_FILE, DEFAULT_EULA_URL } from '../../license-defaults'; - -interface PackageJson { - name: string; - version: string; - repository?: string | { url?: string }; -} +import { DEFAULT_LICENSE_TEMPLATE_EULA, DEFAULT_EULA_URL } from '../add-license-headers/defaults'; +import type { PackageJson } from '../../utils/types'; const SRC_JS_EXCLUDES = [ 'bundles/*.js', @@ -83,34 +78,30 @@ async function copyJsSrcJsonFiles(jsSrcDir: string, outputDir: string): Promise< }); } -async function copyLicenseFiles(licenseSrcDir: string, outputDir: string): Promise { - const licenseOutDir = path.join(outputDir, 'license'); - const cwd = toPosixPath(licenseSrcDir); - const relPaths = await glob('**/*', { cwd, nodir: true }); +async function copyAndNormalizeFiles( + srcDir: string, + outDir: string, + pattern: string, +): Promise { + const cwd = toPosixPath(srcDir); + const relPaths = await glob(pattern, { cwd, nodir: true }); await Promise.all( relPaths.map(async (rel) => { - const dest = path.join(licenseOutDir, rel); + const dest = path.join(outDir, rel); await ensureDir(path.dirname(dest)); - await fs.copyFile(path.join(licenseSrcDir, rel), dest); + await fs.copyFile(path.join(srcDir, rel), dest); const content = await readFileText(dest); await writeFileText(dest, ensureTrailingNewline(normalizeEol(content))); }), ); } +async function copyLicenseFiles(licenseSrcDir: string, outputDir: string): Promise { + await copyAndNormalizeFiles(licenseSrcDir, path.join(outputDir, 'license'), '**/*'); +} + async function copyNpmBinFiles(npmBinDir: string, outputDir: string): Promise { - const binOutDir = path.join(outputDir, 'bin'); - const cwd = toPosixPath(npmBinDir); - const relPaths = await glob('*.js', { cwd, nodir: true }); - await ensureDir(binOutDir); - await Promise.all( - relPaths.map(async (rel) => { - const dest = path.join(binOutDir, rel); - await fs.copyFile(path.join(npmBinDir, rel), dest); - const content = await readFileText(dest); - await writeFileText(dest, ensureTrailingNewline(normalizeEol(content))); - }), - ); + await copyAndNormalizeFiles(npmBinDir, path.join(outputDir, 'bin'), '*.js'); } async function copyDistFiles(artifactsDir: string, outputDir: string): Promise { @@ -157,14 +148,19 @@ const runExecutor: PromiseExecutor = async (options, const webpackConfigSrc = path.resolve(projectRoot, options.webpackConfig); const artifactsDir = path.resolve(projectRoot, options.artifactsDir); const outputDir = path.resolve(projectRoot, options.outputDir); - const licenseTemplatePath = path.resolve( - projectRoot, - options.licenseTemplateFile ?? DEFAULT_LICENSE_TEMPLATE_FILE, - ); + const licenseTemplatePath = options.licenseTemplateFile + ? path.resolve(projectRoot, options.licenseTemplateFile) + : DEFAULT_LICENSE_TEMPLATE_EULA; + let pkg: PackageJson; try { - const pkg = await readJson(path.join(projectRoot, 'package.json')); + pkg = await readJson(path.join(projectRoot, 'package.json')); + } catch (error) { + logError('Failed to read package.json', error); + return { success: false }; + } + try { const webpackConfigDest = path.join(outputDir, 'bin', path.basename(webpackConfigSrc)); await Promise.all([ diff --git a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts index 158a764fdd9f..9795fadeb7b2 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts @@ -50,55 +50,53 @@ describe('PreparePackageJsonExecutor E2E', () => { cleanupTempDir(tempDir); }); - describe('publishConfig removal', () => { - it('should remove publishConfig from package.json', async () => { - const options: NpmPackageExecutorSchema = { - distDirectory: './npm', - }; - - const result = await executor(options, context); + it('should remove publishConfig from package.json', async () => { + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + }; - expect(result.success).toBe(true); + const result = await executor(options, context); - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const distPackageJson = path.join(projectDir, 'npm', 'package.json'); - const distPackage = JSON.parse(await readFileText(distPackageJson)); + expect(result.success).toBe(true); - expect(distPackage.publishConfig).toBeUndefined(); - }); + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distPackageJson = path.join(projectDir, 'npm', 'package.json'); + const distPackage = JSON.parse(await readFileText(distPackageJson)); - it('should preserve all other fields when removing publishConfig', async () => { - const options: NpmPackageExecutorSchema = { - distDirectory: './npm', - }; + expect(distPackage.publishConfig).toBeUndefined(); + }); - await executor(options, context); + it('should preserve all other fields when removing publishConfig', async () => { + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + }; - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const distPackageJson = path.join(projectDir, 'npm', 'package.json'); - const distPackage = JSON.parse(await readFileText(distPackageJson)); + await executor(options, context); - expect(distPackage.name).toBe('@devexpress/test-package'); - expect(distPackage.version).toBe('1.0.0'); - expect(distPackage.description).toBe('Test package for prepare-package-json'); - expect(distPackage.main).toBe('./index.js'); - expect(distPackage.module).toBe('./esm/index.js'); - expect(distPackage.types).toBe('./index.d.ts'); - expect(distPackage.scripts).toEqual({ - build: 'tsc', - test: 'jest', - }); - expect(distPackage.dependencies).toEqual({ - react: '^18.0.0', - }); - expect(distPackage.devDependencies).toEqual({ - typescript: '^4.9.0', - jest: '^29.0.0', - }); - expect(distPackage.keywords).toEqual(['test', 'package']); - expect(distPackage.license).toBe('MIT'); - expect(distPackage.author).toBe('Test Author'); + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distPackageJson = path.join(projectDir, 'npm', 'package.json'); + const distPackage = JSON.parse(await readFileText(distPackageJson)); + + expect(distPackage.name).toBe('@devexpress/test-package'); + expect(distPackage.version).toBe('1.0.0'); + expect(distPackage.description).toBe('Test package for prepare-package-json'); + expect(distPackage.main).toBe('./index.js'); + expect(distPackage.module).toBe('./esm/index.js'); + expect(distPackage.types).toBe('./index.d.ts'); + expect(distPackage.scripts).toEqual({ + build: 'tsc', + test: 'jest', + }); + expect(distPackage.dependencies).toEqual({ + react: '^18.0.0', + }); + expect(distPackage.devDependencies).toEqual({ + typescript: '^4.9.0', + jest: '^29.0.0', }); + expect(distPackage.keywords).toEqual(['test', 'package']); + expect(distPackage.license).toBe('MIT'); + expect(distPackage.author).toBe('Test Author'); }); it('should override name and version with setName and setVersion', async () => { diff --git a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts index 8659376bbbb8..bc07b4065c19 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts +++ b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts @@ -27,27 +27,33 @@ function applyPackageJsonTransformations( transformations: PackageJsonTransformations, versionFromValue?: unknown, ): Record { + const result: Record = { ...pkg }; + if (transformations.setName !== undefined) { - pkg['name'] = transformations.setName; + result['name'] = transformations.setName; } if (transformations.setVersion !== undefined) { - pkg['version'] = transformations.setVersion; + result['version'] = transformations.setVersion; } else if (versionFromValue !== undefined) { - pkg['version'] = versionFromValue; + result['version'] = versionFromValue; } if (transformations.renameInternalPattern !== undefined) { const { find, replace } = transformations.renameInternalPattern; - pkg['name'] = String.prototype.replace.call(String(pkg['name']), new RegExp(find), replace); + result['name'] = String.prototype.replace.call( + String(result['name']), + new RegExp(find), + replace, + ); } const fieldsToRemove = transformations.removeFields ?? [PUBLISH_CONFIG_FIELD]; for (const field of fieldsToRemove) { - delete pkg[field]; + delete result[field]; } - return pkg; + return result; } const runExecutor: PromiseExecutor = async (options, context) => { @@ -73,11 +79,11 @@ const runExecutor: PromiseExecutor = async (options, c versionFromValue = versionSource['version']; } - applyPackageJsonTransformations(pkg, options, versionFromValue); + const transformed = applyPackageJsonTransformations(pkg, options, versionFromValue); const outputFileName = options.outputFileName ?? PACKAGE_JSON_FILE; const distPackageJson = path.join(distDirectory, outputFileName); - await writeJson(distPackageJson, pkg, JSON_INDENT); + await writeJson(distPackageJson, transformed, JSON_INDENT); logger.verbose(`Created ${distPackageJson}`); diff --git a/packages/nx-infra-plugin/src/license-defaults.ts b/packages/nx-infra-plugin/src/license-defaults.ts deleted file mode 100644 index 8b97dc753adf..000000000000 --- a/packages/nx-infra-plugin/src/license-defaults.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const DEFAULT_LICENSE_TEMPLATE_FILE = './build/gulp/license-header.txt'; -export const DEFAULT_EULA_URL = 'https://js.devexpress.com/Licensing/'; diff --git a/packages/nx-infra-plugin/src/utils/concat-content.ts b/packages/nx-infra-plugin/src/utils/concat-content.ts index bdd8ac5c2728..57b71a4496f9 100644 --- a/packages/nx-infra-plugin/src/utils/concat-content.ts +++ b/packages/nx-infra-plugin/src/utils/concat-content.ts @@ -1,4 +1,4 @@ -import { readFileText, writeFileText } from './file-operations'; +import { readFileText, writeFileText, normalizeEol } from './file-operations'; export interface ConcatOptions { sourceFiles: string[]; @@ -36,10 +36,6 @@ function applyTransforms( }, content); } -function normalizeLf(content: string): string { - return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); -} - function applyHeaderFooter(content: string, header?: string, footer?: string): string { let result = content; if (header) result = header + result; @@ -61,7 +57,7 @@ export async function concatFiles(opts: ConcatOptions): Promise { let output = contents.join(opts.separator ?? '\n'); if (opts.normalizeLineEndings !== false) { - output = normalizeLf(output); + output = normalizeEol(output); } output = applyHeaderFooter(output, opts.header, opts.footer); diff --git a/packages/nx-infra-plugin/src/utils/debug-strip.ts b/packages/nx-infra-plugin/src/utils/debug-strip.ts index 7bc194ff8551..2100a38dccff 100644 --- a/packages/nx-infra-plugin/src/utils/debug-strip.ts +++ b/packages/nx-infra-plugin/src/utils/debug-strip.ts @@ -1,4 +1,4 @@ -export const REMOVE_DEBUG_REGEXP: RegExp = /\/{2,}\s{0,}#DEBUG[\s\S]*?\/{2,}\s{0,}#ENDDEBUG/g; +const REMOVE_DEBUG_REGEXP = /\/{2,}\s{0,}#DEBUG[\s\S]*?\/{2,}\s{0,}#ENDDEBUG/g; export function stripDebug(content: string): string { return content.replace(REMOVE_DEBUG_REGEXP, ''); diff --git a/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts b/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts index d5f3c1ec39f4..f65605325d1b 100644 --- a/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts @@ -1,57 +1,87 @@ import * as path from 'path'; -import { buildLicenseBannerRenderer } from './license-banner'; +import { buildLicenseBannerRenderer, extractGitHubUrl } from './license-banner'; import { createTempDir, cleanupTempDir } from './test-utils'; import { writeFileText } from './file-operations'; -it('buildLicenseBannerRenderer compiles template once and returns a sync renderer per file', async () => { - const tempDir = createTempDir('nx-license-renderer-e2e-'); - try { - const templatePath = path.join(tempDir, 'license.txt'); - await writeFileText( - templatePath, - `/*<%= commentType %>\n* <%= file.relative %>\n* Version: <%= version %>\n*/\n`, +describe('buildLicenseBannerRenderer', () => { + it('compiles template once and returns a sync renderer per file', async () => { + const tempDir = createTempDir('nx-license-renderer-e2e-'); + try { + const templatePath = path.join(tempDir, 'license.txt'); + await writeFileText( + templatePath, + `/*<%= commentType %>\n* <%= file.relative %>\n* Version: <%= version %>\n*/\n`, + ); + const pkg = { name: 'test-pkg', version: '1.0.0' }; + const render = await buildLicenseBannerRenderer({ templatePath, pkg, commentType: '*' }); + + const banner1 = render('foo.d.ts'); + const banner2 = render('bar/baz.d.ts'); + + expect(banner1).toMatch(/^\/\*\*/); + expect(banner1).toContain('foo.d.ts'); + expect(banner1).toContain('Version: 1.0.0'); + expect(banner2).toMatch(/^\/\*\*/); + expect(banner2).toContain('bar/baz.d.ts'); + expect(banner2).not.toContain('foo.d.ts'); + } finally { + cleanupTempDir(tempDir); + } + }); + + it('interpolates eulaUrl and year into the rendered banner', async () => { + const tempDir = createTempDir('nx-license-renderer-eula-e2e-'); + try { + const templatePath = path.join(tempDir, 'license.txt'); + await writeFileText( + templatePath, + `/*<%= commentType %>\n* Copyright (c) 2012 - <%= year %> Developer Express Inc.\n* Read about DevExtreme licensing here: <%= eula %>\n*/\n`, + ); + const pkg = { name: 'devextreme', version: '26.1.0' }; + const render = await buildLicenseBannerRenderer({ + templatePath, + pkg, + eulaUrl: 'https://js.devexpress.com/Licensing/', + commentType: '!', + }); + + const banner = render('events/hover.d.ts'); + const currentYear = new Date().getFullYear(); + + expect(banner).toMatch(/^\/\*!/); + expect(banner).toContain('Developer Express Inc.'); + expect(banner).toContain(`2012 - ${currentYear}`); + expect(banner).toContain('https://js.devexpress.com/Licensing/'); + } finally { + cleanupTempDir(tempDir); + } + }); + + it('should extract github URL from string repository field', () => { + const result = extractGitHubUrl('git+https://github.com/foo/bar.git', '/fake/path.json'); + expect(result).toBe('https://github.com/foo/bar'); + }); + + it('should extract github URL from object repository field', () => { + const result = extractGitHubUrl( + { url: 'git+https://github.com/foo/bar.git' }, + '/fake/path.json', ); - const pkg = { name: 'test-pkg', version: '1.0.0' }; - const render = await buildLicenseBannerRenderer({ templatePath, pkg, commentType: '*' }); - - const banner1 = render('foo.d.ts'); - const banner2 = render('bar/baz.d.ts'); - - expect(banner1).toMatch(/^\/\*\*/); - expect(banner1).toContain('foo.d.ts'); - expect(banner1).toContain('Version: 1.0.0'); - expect(banner2).toMatch(/^\/\*\*/); - expect(banner2).toContain('bar/baz.d.ts'); - expect(banner2).not.toContain('foo.d.ts'); - } finally { - cleanupTempDir(tempDir); - } -}); + expect(result).toBe('https://github.com/foo/bar'); + }); -it('buildLicenseBannerRenderer interpolates eulaUrl and year into the rendered banner', async () => { - const tempDir = createTempDir('nx-license-renderer-eula-e2e-'); - try { - const templatePath = path.join(tempDir, 'license.txt'); - await writeFileText( - templatePath, - `/*<%= commentType %>\n* Copyright (c) 2012 - <%= year %> Developer Express Inc.\n* Read about DevExtreme licensing here: <%= eula %>\n*/\n`, + it('should preserve URLs without git+ prefix or .git suffix', () => { + const result = extractGitHubUrl('https://github.com/foo/bar', '/fake/path.json'); + expect(result).toBe('https://github.com/foo/bar'); + }); + + it('should throw when repository is missing', () => { + expect(() => extractGitHubUrl(undefined, '/fake/path.json')).toThrow( + "Missing 'repository' field", ); - const pkg = { name: 'devextreme', version: '26.1.0' }; - const render = await buildLicenseBannerRenderer({ - templatePath, - pkg, - eulaUrl: 'https://js.devexpress.com/Licensing/', - commentType: '!', - }); - - const banner = render('events/hover.d.ts'); - const currentYear = new Date().getFullYear(); - - expect(banner).toMatch(/^\/\*!/); - expect(banner).toContain('Developer Express Inc.'); - expect(banner).toContain(`2012 - ${currentYear}`); - expect(banner).toContain('https://js.devexpress.com/Licensing/'); - } finally { - cleanupTempDir(tempDir); - } + }); + + it('should throw when repository.url is missing on object form', () => { + expect(() => extractGitHubUrl({}, '/fake/path.json')).toThrow("Invalid 'repository' format"); + }); }); diff --git a/packages/nx-infra-plugin/src/utils/license-banner.ts b/packages/nx-infra-plugin/src/utils/license-banner.ts index f5e5a1466d2b..9d80d75bc0ff 100644 --- a/packages/nx-infra-plugin/src/utils/license-banner.ts +++ b/packages/nx-infra-plugin/src/utils/license-banner.ts @@ -1,18 +1,41 @@ import _ from 'lodash'; import { readFileText, writeFileText } from './file-operations'; +import type { PackageJson } from './types'; export interface LicenseBannerOptions { templatePath: string; - pkg: { name: string; version: string; repository?: string | { url?: string } }; + pkg: PackageJson; eulaUrl?: string; version?: string; commentType: '!' | '*'; + githubUrl?: string; +} + +export function extractGitHubUrl( + repository: string | { url?: string } | undefined, + packageJsonPath: string, +): string { + if (!repository) { + throw new Error( + `Missing 'repository' field in ${packageJsonPath}. License headers require a repository URL.`, + ); + } + + const rawUrl = typeof repository === 'string' ? repository : repository.url; + + if (!rawUrl) { + throw new Error( + `Invalid 'repository' format in ${packageJsonPath}. Expected string or object with 'url' property.`, + ); + } + + return rawUrl.replace(/^git\+/, '').replace(/\.git$/, ''); } export async function buildLicenseBannerRenderer( opts: LicenseBannerOptions, ): Promise<(fileRelative: string) => string> { - const { templatePath, pkg, eulaUrl = '', commentType } = opts; + const { templatePath, pkg, eulaUrl = '', commentType, githubUrl = '' } = opts; const resolvedVersion = opts.version ?? pkg.version; const now = new Date(); @@ -27,7 +50,7 @@ export async function buildLicenseBannerRenderer( date: now.toDateString(), year: now.getFullYear(), pkg, - githubUrl: '', + githubUrl, }); } diff --git a/packages/nx-infra-plugin/src/utils/types.ts b/packages/nx-infra-plugin/src/utils/types.ts index c0a6c14e6e85..741c4418fb65 100644 --- a/packages/nx-infra-plugin/src/utils/types.ts +++ b/packages/nx-infra-plugin/src/utils/types.ts @@ -1,3 +1,9 @@ +export interface PackageJson { + name: string; + version: string; + repository?: string | { url?: string }; +} + export interface TsConfig { compilerOptions?: CompilerOptions; extends?: string; From fbf2153d7c8bda11bb1d43af4450771db0c2c6a5 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 4 May 2026 18:51:03 +0300 Subject: [PATCH 12/27] chore(nx-infra-plugin): centralize duplicated logic into shared modules --- .../add-license-headers.impl.ts | 190 +++++ .../executors/add-license-headers/defaults.ts | 17 + .../executors/add-license-headers/executor.ts | 138 +--- .../babel-transform/babel-transform.impl.ts | 152 ++++ .../babel-transform/executor.e2e.spec.ts | 5 +- .../src/executors/babel-transform/executor.ts | 142 +--- .../build-angular-library.impl.ts | 203 ++++++ .../build-angular-library/executor.ts | 203 +----- .../build-typescript/build-typescript.impl.ts | 287 ++++++++ .../executors/build-typescript/executor.ts | 295 +------- .../src/executors/bundle/bundle.impl.ts | 208 ++++++ .../src/executors/bundle/executor.ts | 195 +----- .../src/executors/clean/clean.impl.ts | 135 ++++ .../src/executors/clean/executor.e2e.spec.ts | 227 +----- .../src/executors/clean/executor.ts | 136 +--- .../src/executors/compress/compress.impl.ts | 161 +++++ .../executors/compress/executor.e2e.spec.ts | 39 +- .../src/executors/compress/executor.ts | 172 +---- .../concatenate-files.impl.ts | 176 +++++ .../executors/concatenate-files/executor.ts | 100 +-- .../executors/copy-files/copy-files.impl.ts | 117 ++++ .../src/executors/copy-files/executor.ts | 94 +-- .../create-dual-mode-manifest.impl.ts | 220 ++++++ .../create-dual-mode-manifest/executor.ts | 219 +----- .../executors/dts-bundle/dts-bundle.impl.ts | 83 +++ .../executors/dts-bundle/executor.e2e.spec.ts | 15 +- .../src/executors/dts-bundle/executor.ts | 86 +-- .../executors/dts-modules/dts-modules.impl.ts | 111 +++ .../dts-modules/executor.e2e.spec.ts | 17 +- .../src/executors/dts-modules/executor.ts | 93 +-- .../generate-component-names/executor.ts | 71 +- .../generate-component-names.impl.ts | 72 ++ .../executors/generate-components/executor.ts | 327 +-------- .../generate-components.impl.ts | 351 ++++++++++ .../src/executors/karma-multi-env/executor.ts | 655 +---------------- .../karma-multi-env/karma-multi-env.impl.ts | 661 ++++++++++++++++++ .../src/executors/localization/executor.ts | 444 +----------- .../localization/localization.impl.ts | 464 ++++++++++++ .../npm-assemble/executor.e2e.spec.ts | 38 +- .../src/executors/npm-assemble/executor.ts | 193 +---- .../npm-assemble/npm-assemble.impl.ts | 180 +++++ .../src/executors/pack-npm/executor.ts | 42 +- .../src/executors/pack-npm/pack-npm.impl.ts | 40 ++ .../prepare-package-json/executor.e2e.spec.ts | 74 +- .../prepare-package-json/executor.ts | 98 +-- .../prepare-package-json.impl.ts | 100 +++ .../prepare-submodules/executor.e2e.spec.ts | 42 +- .../executors/prepare-submodules/executor.ts | 163 +---- .../prepare-submodules.impl.ts | 166 +++++ .../scss-assemble/executor.e2e.spec.ts | 13 - .../src/executors/scss-assemble/executor.ts | 110 +-- .../scss-assemble/scss-assemble.impl.ts | 109 +++ .../src/executors/vectormap/executor.ts | 230 +----- .../src/executors/vectormap/vectormap.impl.ts | 230 ++++++ .../src/utils/concat-content.ts | 75 -- .../src/utils/copy-directory.ts | 34 - .../src/utils/create-executor.ts | 44 ++ .../nx-infra-plugin/src/utils/debug-strip.ts | 5 - .../src/utils/file-operations.ts | 6 + .../src/utils/glob-discovery.ts | 63 ++ packages/nx-infra-plugin/src/utils/index.ts | 2 + .../src/utils/path-resolver.ts | 16 + 62 files changed, 4641 insertions(+), 4713 deletions(-) create mode 100644 packages/nx-infra-plugin/src/executors/add-license-headers/add-license-headers.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/babel-transform/babel-transform.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/build-angular-library/build-angular-library.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/build-typescript/build-typescript.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/bundle/bundle.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/clean/clean.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/compress/compress.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/concatenate-files/concatenate-files.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/copy-files/copy-files.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/create-dual-mode-manifest.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/dts-bundle/dts-bundle.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/dts-modules/dts-modules.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/generate-component-names/generate-component-names.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/generate-components/generate-components.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/karma-multi-env/karma-multi-env.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/localization/localization.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/pack-npm/pack-npm.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/prepare-package-json/prepare-package-json.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/prepare-submodules/prepare-submodules.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/vectormap/vectormap.impl.ts delete mode 100644 packages/nx-infra-plugin/src/utils/concat-content.ts delete mode 100644 packages/nx-infra-plugin/src/utils/copy-directory.ts create mode 100644 packages/nx-infra-plugin/src/utils/create-executor.ts delete mode 100644 packages/nx-infra-plugin/src/utils/debug-strip.ts create mode 100644 packages/nx-infra-plugin/src/utils/glob-discovery.ts diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/add-license-headers.impl.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/add-license-headers.impl.ts new file mode 100644 index 000000000000..689914c1ab36 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/add-license-headers.impl.ts @@ -0,0 +1,190 @@ +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { discoverFiles } from '../../utils/glob-discovery'; +import { readJson } from '../../utils/file-operations'; +import { + applyLicenseBannerToFile, + buildLicenseBannerRenderer, + extractGitHubUrl, +} from '../../utils/license-banner'; +import type { PackageJson } from '../../utils/types'; +import { AddLicenseHeadersExecutorSchema } from './schema'; +import { + DEFAULT_EULA_URL, + DEFAULT_EXCLUDE_PATTERNS, + DEFAULT_INCLUDE_PATTERNS, + DEFAULT_LICENSE_TEMPLATE_MIT, + DEFAULT_PACKAGE_JSON, + DEFAULT_TARGET_DIR, + LicenseMode, + resolveLicenseTemplate, +} from './defaults'; + +const FORWARD_SLASH = '/'; +const BACKSLASH_REGEX = /\\/g; +const UNKNOWN_PACKAGE_JSON = ''; +const DEFAULT_SEPARATOR = '\n'; +const DEFAULT_PREPEND_AFTER_LICENSE = ''; + +export type CommentType = '!' | '*'; +export type FilenameMode = 'relative' | 'basename'; + +const DEFAULT_COMMENT_TYPE: CommentType = '!'; +const DEFAULT_FILENAME_MODE: FilenameMode = 'relative'; + +export interface BannerInputs { + pkg: PackageJson; + templatePath: string; + eulaUrl?: string; + mode?: LicenseMode; + packageJsonPath?: string; + version?: string; + commentType?: CommentType; +} + +export interface BannerApplyOptions { + separator?: string; + prependAfterLicense?: string; + filenameMode?: FilenameMode; +} + +type RenderBannerFn = (fileName: string) => string; + +function computeFilename( + filenameMode: FilenameMode | undefined, + baseDir: string, + file: string, +): string { + if (filenameMode === 'basename') { + return path.basename(file); + } + return path.relative(baseDir, file).replace(BACKSLASH_REGEX, FORWARD_SLASH); +} + +async function buildBannerRenderer(inputs: BannerInputs): Promise { + const githubUrl = + inputs.templatePath === DEFAULT_LICENSE_TEMPLATE_MIT || inputs.mode === 'mit' + ? extractGitHubUrl(inputs.pkg.repository, inputs.packageJsonPath ?? UNKNOWN_PACKAGE_JSON) + : ''; + + return buildLicenseBannerRenderer({ + templatePath: inputs.templatePath, + pkg: inputs.pkg, + eulaUrl: inputs.eulaUrl ?? DEFAULT_EULA_URL, + version: inputs.version, + commentType: inputs.commentType ?? DEFAULT_COMMENT_TYPE, + githubUrl, + }); +} + +export async function renderLicenseBannerForName( + inputs: BannerInputs, + fileName: string, +): Promise { + const renderer = await buildBannerRenderer(inputs); + return renderer(fileName); +} + +export interface ApplyLicenseHeadersToFilesOptions extends BannerInputs, BannerApplyOptions { + files: readonly string[]; + baseDir: string; +} + +export async function applyLicenseHeadersToFiles( + opts: ApplyLicenseHeadersToFilesOptions, +): Promise { + const renderer = await buildBannerRenderer(opts); + await Promise.all( + opts.files.map(async (file) => { + const banner = renderer(computeFilename(opts.filenameMode, opts.baseDir, file)); + await applyLicenseBannerToFile(file, banner, { + separator: opts.separator, + prependAfterLicense: opts.prependAfterLicense, + }); + }), + ); +} + +export interface ApplyLicenseHeadersToDirectoryOptions extends BannerInputs, BannerApplyOptions { + targetDir: string; + includePatterns?: readonly string[]; + excludePatterns?: readonly string[]; +} + +export async function applyLicenseHeadersToDirectory( + opts: ApplyLicenseHeadersToDirectoryOptions, +): Promise { + const files = await discoverFiles({ + cwd: opts.targetDir, + includePatterns: opts.includePatterns ?? DEFAULT_INCLUDE_PATTERNS, + excludePatterns: opts.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS, + }); + + await applyLicenseHeadersToFiles({ + ...opts, + files, + baseDir: opts.targetDir, + }); + + return files.length; +} + +interface ResolvedAddLicenseHeaders { + targetDir: string; + pkg: PackageJson; + templatePath: string; + eulaUrl: string; + mode?: LicenseMode; + packageJsonPath: string; + version?: string; + commentType: CommentType; + separator: string; + prependAfterLicense: string; + includePatterns: readonly string[]; + excludePatterns: readonly string[]; +} + +export default createExecutor({ + name: 'AddLicenseHeaders', + resolve: async (options, { projectRoot }) => { + const packageJsonPath = path.join(projectRoot, options.packageJsonPath ?? DEFAULT_PACKAGE_JSON); + const targetDir = path.join(projectRoot, options.targetDirectory ?? DEFAULT_TARGET_DIR); + const templatePath = resolveLicenseTemplate(projectRoot, options); + const pkg = await readJson(packageJsonPath); + + return { + targetDir, + pkg, + templatePath, + eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, + mode: options.mode, + packageJsonPath, + version: options.version, + commentType: options.commentType ?? DEFAULT_COMMENT_TYPE, + separator: options.separatorBetweenBannerAndContent ?? DEFAULT_SEPARATOR, + prependAfterLicense: options.prependAfterLicense ?? DEFAULT_PREPEND_AFTER_LICENSE, + includePatterns: options.includePatterns ?? DEFAULT_INCLUDE_PATTERNS, + excludePatterns: options.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS, + }; + }, + run: async (resolved) => { + const count = await applyLicenseHeadersToDirectory({ + targetDir: resolved.targetDir, + pkg: resolved.pkg, + templatePath: resolved.templatePath, + eulaUrl: resolved.eulaUrl, + mode: resolved.mode, + packageJsonPath: resolved.packageJsonPath, + version: resolved.version, + commentType: resolved.commentType, + separator: resolved.separator, + prependAfterLicense: resolved.prependAfterLicense, + includePatterns: resolved.includePatterns, + excludePatterns: resolved.excludePatterns, + filenameMode: DEFAULT_FILENAME_MODE, + }); + logger.verbose(`Adding license headers to ${count} files...`); + logger.verbose('License headers added successfully'); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/defaults.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/defaults.ts index 9b1514f2a758..889103ced5be 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/defaults.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/defaults.ts @@ -8,3 +8,20 @@ export const DEFAULT_TARGET_DIR = './npm'; export const DEFAULT_PACKAGE_JSON = './package.json'; export const DEFAULT_INCLUDE_PATTERNS = ['**/*.{ts,js}'] as const; export const DEFAULT_EXCLUDE_PATTERNS = ['**/*.json', '**/*.map'] as const; + +export type LicenseMode = 'eula' | 'mit'; + +export interface LicenseTemplateOptions { + licenseTemplateFile?: string; + mode?: LicenseMode; +} + +export function resolveLicenseTemplate( + projectRoot: string, + options: LicenseTemplateOptions, +): string { + if (options.licenseTemplateFile) { + return path.join(projectRoot, options.licenseTemplateFile); + } + return options.mode === 'mit' ? DEFAULT_LICENSE_TEMPLATE_MIT : DEFAULT_LICENSE_TEMPLATE_EULA; +} diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts index 92eb65c9a955..e2d5ec8dd859 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.ts @@ -1,126 +1,12 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import { glob } from 'glob'; -import { AddLicenseHeadersExecutorSchema } from './schema'; -import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; -import { readJson } from '../../utils/file-operations'; -import { - buildLicenseBannerRenderer, - applyLicenseBannerToFile, - extractGitHubUrl, -} from '../../utils/license-banner'; -import type { PackageJson } from '../../utils/types'; -import { - DEFAULT_LICENSE_TEMPLATE_EULA, - DEFAULT_LICENSE_TEMPLATE_MIT, - DEFAULT_EULA_URL, - DEFAULT_TARGET_DIR, - DEFAULT_PACKAGE_JSON, - DEFAULT_INCLUDE_PATTERNS, - DEFAULT_EXCLUDE_PATTERNS, -} from './defaults'; - -function resolveTemplatePath( - absoluteProjectRoot: string, - options: { licenseTemplateFile?: string; mode?: 'eula' | 'mit' }, -): string { - if (options.licenseTemplateFile) { - return path.join(absoluteProjectRoot, options.licenseTemplateFile); - } - if (options.mode === 'mit') { - return DEFAULT_LICENSE_TEMPLATE_MIT; - } - return DEFAULT_LICENSE_TEMPLATE_EULA; -} - -interface DiscoverFilesOptions { - targetDirectory: string; - includePatterns: readonly string[]; - excludePatterns: readonly string[]; -} - -async function discoverFiles(options: DiscoverFilesOptions): Promise { - const { targetDirectory, includePatterns, excludePatterns } = options; - const cwd = toPosixPath(targetDirectory); - - const allFiles: string[] = []; - for (const pattern of includePatterns) { - const matchedFiles = await glob(pattern, { - cwd, - absolute: true, - nodir: true, - ignore: [...excludePatterns], - }); - allFiles.push(...matchedFiles); - } - - return [...new Set(allFiles)]; -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const absoluteProjectRoot = resolveProjectPath(context); - const targetDirectory = path.join( - absoluteProjectRoot, - options.targetDirectory ?? DEFAULT_TARGET_DIR, - ); - const packageJsonPath = path.join( - absoluteProjectRoot, - options.packageJsonPath ?? DEFAULT_PACKAGE_JSON, - ); - const separator = options.separatorBetweenBannerAndContent ?? '\n'; - const prependAfterLicense = options.prependAfterLicense ?? ''; - const commentType = options.commentType ?? '!'; - const templatePath = resolveTemplatePath(absoluteProjectRoot, options); - - let pkg: PackageJson; - try { - pkg = await readJson(packageJsonPath); - } catch (error) { - logError('Failed to read package.json', error); - return { success: false }; - } - - const githubUrl = - templatePath === DEFAULT_LICENSE_TEMPLATE_MIT - ? extractGitHubUrl(pkg.repository, packageJsonPath) - : ''; - - try { - const files = await discoverFiles({ - targetDirectory, - includePatterns: options.includePatterns ?? DEFAULT_INCLUDE_PATTERNS, - excludePatterns: options.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS, - }); - - logger.verbose(`Adding license headers to ${files.length} files...`); - - const renderBanner = await buildLicenseBannerRenderer({ - templatePath, - pkg, - eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, - version: options.version, - commentType, - githubUrl, - }); - - await Promise.all( - files.map(async (file) => { - const fileRelative = path.relative(targetDirectory, file).replace(/\\/g, '/'); - const banner = renderBanner(fileRelative); - await applyLicenseBannerToFile(file, banner, { - separator, - prependAfterLicense, - }); - }), - ); - - logger.verbose('License headers added successfully'); - return { success: true }; - } catch (error) { - logError('Failed to add license headers', error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './add-license-headers.impl'; +export { + applyLicenseHeadersToDirectory, + applyLicenseHeadersToFiles, + renderLicenseBannerForName, + ApplyLicenseHeadersToDirectoryOptions, + ApplyLicenseHeadersToFilesOptions, + BannerInputs, + BannerApplyOptions, + CommentType, + FilenameMode, +} from './add-license-headers.impl'; diff --git a/packages/nx-infra-plugin/src/executors/babel-transform/babel-transform.impl.ts b/packages/nx-infra-plugin/src/executors/babel-transform/babel-transform.impl.ts new file mode 100644 index 000000000000..c3efe15af52f --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/babel-transform/babel-transform.impl.ts @@ -0,0 +1,152 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; +import * as babel from '@babel/core'; +import { glob } from 'glob'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { toPosixPath } from '../../utils/path-resolver'; +import { stripDebug } from '../compress/compress.impl'; +import { BabelTransformExecutorSchema } from './schema'; + +const ERROR_NO_FILES_MATCHED = 'No files matched the source pattern'; + +function loadBabelConfig( + projectRoot: string, + configPath: string, + configKey: string, +): babel.TransformOptions { + const fullConfigPath = path.join(projectRoot, configPath); + + if (!fs.existsSync(fullConfigPath)) { + throw new Error(`Babel config not found: ${fullConfigPath}`); + } + + const config = require(fullConfigPath); + + if (!config[configKey]) { + const availableKeys = Object.keys(config).join(', '); + throw new Error(`Config key '${configKey}' not found. Available: ${availableKeys}`); + } + + return config[configKey]; +} + +function applyExtensionRenames(filePath: string, renameExtensions: Record): string { + const ext = path.extname(filePath); + + if (ext && ext in renameExtensions) { + return filePath.slice(0, -ext.length) + renameExtensions[ext]; + } + + return filePath; +} + +async function transformFile( + filePath: string, + projectRoot: string, + outDir: string, + sourcePattern: string, + babelConfig: babel.TransformOptions, + removeDebug: boolean, + renameExtensions: Record, +): Promise { + let content = await fs.readFile(filePath, 'utf-8'); + + if (removeDebug) { + content = stripDebug(content); + } + + const result = await babel.transformAsync(content, { + ...babelConfig, + filename: filePath, + }); + + if (!result?.code) { + throw new Error(`Babel returned no code for ${filePath}`); + } + + const cleanPattern = sourcePattern.replace(/^\.\//, ''); + const globIndex = cleanPattern.search(/\*+/); + const patternBase = + globIndex > 0 + ? cleanPattern.substring(0, globIndex).replace(/\/$/, '') + : cleanPattern.split('/')[0]; + const sourceBase = path.join(projectRoot, patternBase); + const relativePath = path.relative(sourceBase, filePath); + + const renamedRelativePath = applyExtensionRenames(relativePath, renameExtensions); + const outputPath = path.join(projectRoot, outDir, renamedRelativePath); + + await fs.ensureDir(path.dirname(outputPath)); + await fs.writeFile(outputPath, result.code); +} + +interface ResolvedBabelTransform { + projectRoot: string; + babelConfig: babel.TransformOptions; + removeDebug: boolean; + renameExtensions: Record; + globPattern: string; + excludePatterns: string[]; +} + +export default createExecutor({ + name: 'BabelTransform', + resolve: (options, { projectRoot }) => { + const babelConfig = loadBabelConfig(projectRoot, options.babelConfigPath, options.configKey); + const removeDebug = options.removeDebug ?? false; + const renameExtensions = options.renameExtensions ?? {}; + + const sourcePath = path.join(projectRoot, options.sourcePattern); + const globPattern = toPosixPath(sourcePath); + + const rawExcludePatterns = options.excludePatterns ?? []; + const excludePatterns = rawExcludePatterns.map((pattern) => { + const resolved = path.isAbsolute(pattern) ? pattern : path.join(projectRoot, pattern); + return toPosixPath(resolved); + }); + + return { + projectRoot, + babelConfig, + removeDebug, + renameExtensions, + globPattern, + excludePatterns, + }; + }, + run: async (resolved, options) => { + const sourceFiles = await glob(resolved.globPattern, { + absolute: true, + ignore: resolved.excludePatterns, + }); + + if (sourceFiles.length === 0) { + logger.warn(ERROR_NO_FILES_MATCHED); + throw new Error(ERROR_NO_FILES_MATCHED); + } + + logger.verbose( + `Transforming ${sourceFiles.length} files with config '${options.configKey}'...`, + ); + if (resolved.removeDebug) { + logger.verbose('Debug blocks will be removed (production mode)'); + } + + await Promise.all( + sourceFiles.map((file) => + transformFile( + file, + resolved.projectRoot, + options.outDir, + options.sourcePattern, + resolved.babelConfig, + resolved.removeDebug, + resolved.renameExtensions, + ), + ), + ); + + logger.verbose(`Successfully transformed ${sourceFiles.length} files to ${options.outDir}`); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/babel-transform/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/babel-transform/executor.e2e.spec.ts index 439f71b4f1c8..bf35f0bd6dcb 100644 --- a/packages/nx-infra-plugin/src/executors/babel-transform/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/babel-transform/executor.e2e.spec.ts @@ -143,7 +143,7 @@ export function helper() { }, 30000); }); - it('should remove DEBUG blocks', async () => { + it('should forward removeDebug option to stripDebug helper', async () => { const options: BabelTransformExecutorSchema = { babelConfigPath: './build/gulp/transpile-config.js', configKey: 'cjs', @@ -160,8 +160,5 @@ export function helper() { ); expect(utilsContent).not.toContain('This is debug code'); - expect(utilsContent).not.toContain('debugOnly'); - expect(utilsContent).toContain('helper'); - expect(utilsContent).toContain('something'); }, 30000); }); 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 7a0aa4773a69..f69a30d9286a 100644 --- a/packages/nx-infra-plugin/src/executors/babel-transform/executor.ts +++ b/packages/nx-infra-plugin/src/executors/babel-transform/executor.ts @@ -1,141 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import * as fs from 'fs-extra'; -import * as babel from '@babel/core'; -import { glob } from 'glob'; -import { BabelTransformExecutorSchema } from './schema'; -import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; - -function removeDebugBlocks(content: string): string { - return content.replace(/\/{2,}\s*#DEBUG[\s\S]*?\/{2,}\s*#ENDDEBUG/g, ''); -} - -function loadBabelConfig( - projectRoot: string, - configPath: string, - configKey: string, -): babel.TransformOptions { - const fullConfigPath = path.join(projectRoot, configPath); - - if (!fs.existsSync(fullConfigPath)) { - throw new Error(`Babel config not found: ${fullConfigPath}`); - } - - const config = require(fullConfigPath); - - if (!config[configKey]) { - const availableKeys = Object.keys(config).join(', '); - throw new Error(`Config key '${configKey}' not found. Available: ${availableKeys}`); - } - - return config[configKey]; -} - -function applyExtensionRenames(filePath: string, renameExtensions: Record): string { - const ext = path.extname(filePath); - - if (ext && ext in renameExtensions) { - return filePath.slice(0, -ext.length) + renameExtensions[ext]; - } - - return filePath; -} - -async function transformFile( - filePath: string, - projectRoot: string, - outDir: string, - sourcePattern: string, - babelConfig: babel.TransformOptions, - removeDebug: boolean, - renameExtensions: Record, -): Promise { - let content = await fs.readFile(filePath, 'utf-8'); - - if (removeDebug) { - content = removeDebugBlocks(content); - } - - const result = await babel.transformAsync(content, { - ...babelConfig, - filename: filePath, - }); - - if (!result?.code) { - throw new Error(`Babel returned no code for ${filePath}`); - } - - const cleanPattern = sourcePattern.replace(/^\.\//, ''); - const globIndex = cleanPattern.search(/\*+/); - const patternBase = - globIndex > 0 - ? cleanPattern.substring(0, globIndex).replace(/\/$/, '') - : cleanPattern.split('/')[0]; - const sourceBase = path.join(projectRoot, patternBase); - const relativePath = path.relative(sourceBase, filePath); - - const renamedRelativePath = applyExtensionRenames(relativePath, renameExtensions); - const outputPath = path.join(projectRoot, outDir, renamedRelativePath); - - await fs.ensureDir(path.dirname(outputPath)); - await fs.writeFile(outputPath, result.code); -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - - try { - const babelConfig = loadBabelConfig(projectRoot, options.babelConfigPath, options.configKey); - const removeDebug = options.removeDebug ?? false; - const renameExtensions = options.renameExtensions ?? {}; - - const sourcePath = path.join(projectRoot, options.sourcePattern); - const globPattern = toPosixPath(sourcePath); - - const rawExcludePatterns = options.excludePatterns ?? []; - const excludePatterns = rawExcludePatterns.map((pattern) => { - const resolved = path.isAbsolute(pattern) ? pattern : path.join(projectRoot, pattern); - return toPosixPath(resolved); - }); - - const sourceFiles = await glob(globPattern, { - absolute: true, - ignore: excludePatterns, - }); - - if (sourceFiles.length === 0) { - logger.warn('No files matched the source pattern'); - return { success: false }; - } - - logger.verbose( - `Transforming ${sourceFiles.length} files with config '${options.configKey}'...`, - ); - if (removeDebug) { - logger.verbose('Debug blocks will be removed (production mode)'); - } - - await Promise.all( - sourceFiles.map((file) => - transformFile( - file, - projectRoot, - options.outDir, - options.sourcePattern, - babelConfig, - removeDebug, - renameExtensions, - ), - ), - ); - - logger.verbose(`Successfully transformed ${sourceFiles.length} files to ${options.outDir}`); - return { success: true }; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - logger.error(`Babel transform failed: ${errorMsg}`); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './babel-transform.impl'; diff --git a/packages/nx-infra-plugin/src/executors/build-angular-library/build-angular-library.impl.ts b/packages/nx-infra-plugin/src/executors/build-angular-library/build-angular-library.impl.ts new file mode 100644 index 000000000000..b62a652354d4 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/build-angular-library/build-angular-library.impl.ts @@ -0,0 +1,203 @@ +import { logger, ExecutorContext } from '@nx/devkit'; +import * as path from 'path'; +import { spawn } from 'child_process'; +import { createExecutor } from '../../utils/create-executor'; +import { BuildAngularLibraryExecutorSchema } from './schema'; +import { resolveFromProject } from '../../utils/path-resolver'; +import { exists } from '../../utils/file-operations'; + +interface BuildConfiguration { + ngPackagePath: string; + tsconfigPath: string; + projectDir: string; + ngPackagrPath: string; + args: string[]; + projectName: string; +} + +interface BuildResult { + success: boolean; + message?: string; + error?: string; + exitCode?: number; + signal?: string | undefined; + duration?: number; +} + +const CONFIG = { + DEFAULT_TSCONFIG: './tsconfig.lib.json', + NG_PACKAGR_BIN_PATH: 'node_modules/.bin/ng-packagr', + NG_PACKAGR_ARGS: { + PACKAGE: '-p', + CONFIG: '-c', + }, +} as const; + +const ERROR_MESSAGES = { + MISSING_NG_PACKAGE: (filePath: string) => `ng-package.json file not found at: ${filePath}`, + MISSING_TSCONFIG: (filePath: string) => `TypeScript config file not found at: ${filePath}`, + BUILD_FAILED: (project: string, exitCode?: number) => + `Angular library build failed for project ${project}${ + exitCode ? ` (exit code: ${exitCode})` : '' + }`, + PROJECT_ROOT_NOT_FOUND: (project: string) => + `Could not determine project root directory for project: ${project}`, +} as const; + +function resolveBuildConfiguration( + options: BuildAngularLibraryExecutorSchema, + context: ExecutorContext, +): BuildConfiguration { + const ngPackagePath = resolveFromProject(context, options.project); + const tsconfigPath = resolveFromProject(context, options.tsConfig || CONFIG.DEFAULT_TSCONFIG); + const projectRoot = context.projectsConfigurations?.projects[context.projectName!]?.root; + + if (!projectRoot) { + throw new Error(ERROR_MESSAGES.PROJECT_ROOT_NOT_FOUND(context.projectName || 'unknown')); + } + + const projectDir = path.resolve(context.root, projectRoot); + + const relativeNgPackage = path.relative(projectDir, ngPackagePath); + const relativeTsConfig = path.relative(projectDir, tsconfigPath); + + return { + ngPackagePath, + tsconfigPath, + projectDir, + ngPackagrPath: path.join(projectDir, CONFIG.NG_PACKAGR_BIN_PATH), + args: [ + CONFIG.NG_PACKAGR_ARGS.PACKAGE, + relativeNgPackage, + CONFIG.NG_PACKAGR_ARGS.CONFIG, + relativeTsConfig, + ], + projectName: context.projectName || 'unknown', + }; +} + +async function validateConfigFiles(ngPackagePath: string, tsconfigPath: string): Promise { + if (!(await exists(ngPackagePath))) { + throw new Error(ERROR_MESSAGES.MISSING_NG_PACKAGE(ngPackagePath)); + } + + if (!(await exists(tsconfigPath))) { + throw new Error(ERROR_MESSAGES.MISSING_TSCONFIG(tsconfigPath)); + } +} + +async function validateBuildConfiguration(config: BuildConfiguration): Promise { + await validateConfigFiles(config.ngPackagePath, config.tsconfigPath); +} + +function spawnProcess(spawnArgs: { + command: string; + args: string[]; + options: any; +}): Promise<{ exitCode: number; signal?: string | undefined }> { + return new Promise((resolve, reject) => { + logger.verbose(`Spawning process: ${spawnArgs.command} ${spawnArgs.args.join(' ')}`); + + const child = spawn(spawnArgs.command, spawnArgs.args, spawnArgs.options); + + child.on('close', (code, signal) => { + logger.verbose(`Process closed with code: ${code}, signal: ${signal || 'none'}`); + const actualExitCode = code === null ? -1 : code; + resolve({ exitCode: actualExitCode, signal: signal || undefined }); + }); + + child.on('error', (error) => { + logger.error(`Process error: ${error instanceof Error ? error.message : String(error)}`); + reject(error); + }); + }); +} + +async function executeNgPackagrBuild(config: BuildConfiguration): Promise { + const startTime = Date.now(); + + try { + logger.verbose( + `Using ng-package config: ${path.relative(config.projectDir, config.ngPackagePath)}`, + ); + logger.verbose( + `Using TypeScript config: ${path.relative(config.projectDir, config.tsconfigPath)}`, + ); + logger.verbose(`Running ng-packagr from: ${config.projectDir}`); + logger.verbose(`Executing: ${config.ngPackagrPath} ${config.args.join(' ')}`); + + const { exitCode, signal } = await spawnProcess({ + command: config.ngPackagrPath, + args: config.args, + options: { + cwd: config.projectDir, + stdio: 'inherit' as const, + shell: true, + }, + }); + + if (exitCode === 0) { + logger.verbose(`✓ ng-packagr completed successfully (exitCode: ${exitCode})`); + return { + success: true, + message: '✓ Angular library build completed successfully', + exitCode, + duration: Date.now() - startTime, + }; + } else { + logger.error(`✗ ng-packagr failed with exit code ${exitCode}`); + return { + success: false, + message: `ng-packagr exited with code ${exitCode}`, + exitCode, + signal, + duration: Date.now() - startTime, + }; + } + } catch (error) { + logger.error(`Process spawn error: ${error instanceof Error ? error.message : String(error)}`); + logger.error(`Stack trace: ${error instanceof Error ? error.stack : 'No stack available'}`); + return { + success: false, + message: 'Failed to execute ng-packagr', + error: error instanceof Error ? error.message : String(error), + exitCode: -1, + duration: Date.now() - startTime, + }; + } +} + +async function withWorkingDirectory(directory: string, operation: () => Promise): Promise { + const originalCwd = process.cwd(); + + try { + process.chdir(directory); + return await operation(); + } finally { + process.chdir(originalCwd); + } +} + +interface ResolvedBuildAngularLibrary { + config: BuildConfiguration; +} + +export default createExecutor({ + name: 'BuildAngularLibrary', + resolve: async (options, { context }) => { + const config = resolveBuildConfiguration(options, context); + await validateBuildConfiguration(config); + return { config }; + }, + run: async ({ config }) => { + logger.verbose('Building Angular library with ng-packagr...'); + + const buildResult = await withWorkingDirectory(config.projectDir, () => + executeNgPackagrBuild(config), + ); + + if (!buildResult.success) { + throw new Error(ERROR_MESSAGES.BUILD_FAILED(config.projectName, buildResult.exitCode)); + } + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/build-angular-library/executor.ts b/packages/nx-infra-plugin/src/executors/build-angular-library/executor.ts index 31dcab78e85d..1669ffd82d4f 100644 --- a/packages/nx-infra-plugin/src/executors/build-angular-library/executor.ts +++ b/packages/nx-infra-plugin/src/executors/build-angular-library/executor.ts @@ -1,202 +1 @@ -import { PromiseExecutor, logger, ExecutorContext } from '@nx/devkit'; -import * as path from 'path'; -import { spawn } from 'child_process'; -import { BuildAngularLibraryExecutorSchema } from './schema'; -import { resolveFromProject } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; -import { exists } from '../../utils/file-operations'; - -interface BuildConfiguration { - ngPackagePath: string; - tsconfigPath: string; - projectDir: string; - ngPackagrPath: string; - args: string[]; - projectName: string; -} - -interface BuildResult { - success: boolean; - message?: string; - error?: string; - exitCode?: number; - signal?: string | undefined; - duration?: number; -} - -const CONFIG = { - DEFAULT_TSCONFIG: './tsconfig.lib.json', - NG_PACKAGR_BIN_PATH: 'node_modules/.bin/ng-packagr', - NG_PACKAGR_ARGS: { - PACKAGE: '-p', - CONFIG: '-c', - }, -} as const; - -const ERROR_MESSAGES = { - MISSING_NG_PACKAGE: (path: string) => `ng-package.json file not found at: ${path}`, - MISSING_TSCONFIG: (path: string) => `TypeScript config file not found at: ${path}`, - BUILD_FAILED: (project: string, exitCode?: number) => - `Angular library build failed for project ${project}${ - exitCode ? ` (exit code: ${exitCode})` : '' - }`, - PROJECT_ROOT_NOT_FOUND: (project: string) => - `Could not determine project root directory for project: ${project}`, -} as const; - -function resolveBuildConfiguration( - options: BuildAngularLibraryExecutorSchema, - context: ExecutorContext, -): BuildConfiguration { - const ngPackagePath = resolveFromProject(context, options.project); - const tsconfigPath = resolveFromProject(context, options.tsConfig || CONFIG.DEFAULT_TSCONFIG); - const projectRoot = context.projectsConfigurations?.projects[context.projectName!]?.root; - - if (!projectRoot) { - throw new Error(ERROR_MESSAGES.PROJECT_ROOT_NOT_FOUND(context.projectName || 'unknown')); - } - - const projectDir = path.resolve(context.root, projectRoot); - - const relativeNgPackage = path.relative(projectDir, ngPackagePath); - const relativeTsConfig = path.relative(projectDir, tsconfigPath); - - return { - ngPackagePath, - tsconfigPath, - projectDir, - ngPackagrPath: path.join(projectDir, CONFIG.NG_PACKAGR_BIN_PATH), - args: [ - CONFIG.NG_PACKAGR_ARGS.PACKAGE, - relativeNgPackage, - CONFIG.NG_PACKAGR_ARGS.CONFIG, - relativeTsConfig, - ], - projectName: context.projectName || 'unknown', - }; -} - -async function validateBuildConfiguration(config: BuildConfiguration): Promise { - await validateConfigFiles(config.ngPackagePath, config.tsconfigPath); -} - -async function executeNgPackagrBuild(config: BuildConfiguration): Promise { - const startTime = Date.now(); - - try { - logger.verbose( - `Using ng-package config: ${path.relative(config.projectDir, config.ngPackagePath)}`, - ); - logger.verbose( - `Using TypeScript config: ${path.relative(config.projectDir, config.tsconfigPath)}`, - ); - logger.verbose(`Running ng-packagr from: ${config.projectDir}`); - logger.verbose(`Executing: ${config.ngPackagrPath} ${config.args.join(' ')}`); - - const { exitCode, signal } = await spawnProcess({ - command: config.ngPackagrPath, - args: config.args, - options: { - cwd: config.projectDir, - stdio: 'inherit' as const, - shell: true, - }, - }); - - if (exitCode === 0) { - logger.verbose(`✓ ng-packagr completed successfully (exitCode: ${exitCode})`); - return { - success: true, - message: '✓ Angular library build completed successfully', - exitCode, - duration: Date.now() - startTime, - }; - } else { - logger.error(`✗ ng-packagr failed with exit code ${exitCode}`); - return { - success: false, - message: `ng-packagr exited with code ${exitCode}`, - exitCode, - signal, - duration: Date.now() - startTime, - }; - } - } catch (error) { - logger.error(`Process spawn error: ${error instanceof Error ? error.message : String(error)}`); - logger.error(`Stack trace: ${error instanceof Error ? error.stack : 'No stack available'}`); - return { - success: false, - message: 'Failed to execute ng-packagr', - error: error instanceof Error ? error.message : String(error), - exitCode: -1, - duration: Date.now() - startTime, - }; - } -} - -function spawnProcess(options: { - command: string; - args: string[]; - options: any; -}): Promise<{ exitCode: number; signal?: string | undefined }> { - return new Promise((resolve, reject) => { - logger.verbose(`Spawning process: ${options.command} ${options.args.join(' ')}`); - - const child = spawn(options.command, options.args, options.options); - - child.on('close', (code, signal) => { - logger.verbose(`Process closed with code: ${code}, signal: ${signal || 'none'}`); - const actualExitCode = code === null ? -1 : code; - resolve({ exitCode: actualExitCode, signal: signal || undefined }); - }); - - child.on('error', (error) => { - logger.error(`Process error: ${error instanceof Error ? error.message : String(error)}`); - reject(error); - }); - }); -} - -async function withWorkingDirectory(directory: string, operation: () => Promise): Promise { - const originalCwd = process.cwd(); - - try { - process.chdir(directory); - return await operation(); - } finally { - process.chdir(originalCwd); - } -} - -async function validateConfigFiles(ngPackagePath: string, tsconfigPath: string): Promise { - if (!(await exists(ngPackagePath))) { - throw new Error(ERROR_MESSAGES.MISSING_NG_PACKAGE(ngPackagePath)); - } - - if (!(await exists(tsconfigPath))) { - throw new Error(ERROR_MESSAGES.MISSING_TSCONFIG(tsconfigPath)); - } -} - -const runExecutor: PromiseExecutor = async ( - options, - context, -) => { - try { - logger.verbose('Building Angular library with ng-packagr...'); - - const config = resolveBuildConfiguration(options, context); - await validateBuildConfiguration(config); - - const buildResult = await withWorkingDirectory(config.projectDir, () => - executeNgPackagrBuild(config), - ); - - return { success: buildResult.success }; - } catch (error) { - logError(ERROR_MESSAGES.BUILD_FAILED(context.projectName || 'unknown'), error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './build-angular-library.impl'; diff --git a/packages/nx-infra-plugin/src/executors/build-typescript/build-typescript.impl.ts b/packages/nx-infra-plugin/src/executors/build-typescript/build-typescript.impl.ts new file mode 100644 index 000000000000..8b9d31ba0ce3 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/build-typescript/build-typescript.impl.ts @@ -0,0 +1,287 @@ +import { logger } from '@nx/devkit'; +import * as ts from 'typescript'; +import * as path from 'path'; +import { glob } from 'glob'; +import { prepareSingleFileReplaceTscAliasPaths } from 'tsc-alias'; +import { createExecutor } from '../../utils/create-executor'; +import { BuildTypescriptExecutorSchema } from './schema'; +import { TsConfig, CompilerOptions } from '../../utils/types'; +import { normalizeGlobPathForWindows, toPosixPath } from '../../utils/path-resolver'; +import { isWindowsOS } from '../../utils/common'; +import { exists, ensureDir, writeFileText } from '../../utils/file-operations'; + +type AliasTranspileFunc = (filePath: string, fileContents: string) => string; + +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 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', +} 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, + projectRoot: string, +): ResolvedConfig { + 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(ERROR_MESSAGES.TSCONFIG_NOT_FOUND(tsconfigPath)); + } + + 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 = toPosixPath(path.join(projectRoot, srcPattern)); + + const resolvedExcludes = excludePatterns.map((pattern) => { + const result = path.join(projectRoot, pattern); + return toPosixPath(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 replacePathPrefix(filePath: string, from: string, to: string): string { + if (isWindowsOS()) { + return normalizeGlobPathForWindows(filePath).replace( + normalizeGlobPathForWindows(from), + normalizeGlobPathForWindows(to), + ); + } + + return filePath.replace(from, to); +} + +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) { + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start!); + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, NEWLINE_CHAR); + return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`; + } + return ts.flattenDiagnosticMessageText(diagnostic.messageText, NEWLINE_CHAR); + }); +} + +async function emitWithAliasResolution( + program: ts.Program, + emitOptions: EmitOptions, +): Promise<{ success: boolean; diagnostics: ts.Diagnostic[] }> { + const { aliasTranspileFunc, outDir, aliasPath } = emitOptions; + const emittedFiles: Array<{ path: string; content: string }> = []; + + const result = program.emit(undefined, (filePath, fileData) => { + let finalContent = fileData; + + if (aliasTranspileFunc && aliasPath) { + const normalizedFilePath = replacePathPrefix(filePath, 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 }; +} + +async function emitWithPathAliasResolution( + program: ts.Program, + config: ResolvedConfig, +): Promise { + const aliasTranspileFunc = await createAliasTranspileFunc( + config.tsconfigPath, + config.resolvePathsBaseDir!, + ); + + logger.verbose(`Path alias resolution enabled with base dir: ${config.resolvePathsBaseDir}`); + + const { success, diagnostics } = await emitWithAliasResolution(program, { + aliasTranspileFunc, + outDir: config.outDir, + aliasPath: config.resolvePathsBaseDir, + }); + + if (!success) { + logger.error(ERROR_MESSAGES.COMPILATION_FAILED); + formatDiagnostics(diagnostics).forEach((message) => logger.error(message)); + } + + 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((message) => logger.error(message)); + return { success: false }; + } + + return { success: true }; +} + +interface ResolvedBuildTypescript { + config: ResolvedConfig; +} + +export default createExecutor({ + name: 'BuildTypescript', + resolve: async (options, { projectRoot }) => { + const config = resolveExecutorConfig(options, projectRoot); + validateOptions(config); + return { config }; + }, + run: async ({ config }) => { + const { content: tsconfigContent, compilerOptions } = await loadTsConfig(config.tsconfigPath); + compilerOptions.outDir = config.outDir; + await ensureDir(config.outDir); + + const sourceFiles = await resolveSourceFiles( + config.projectRoot, + config.srcPattern, + config.excludePatterns, + ); + + logger.verbose( + `Building ${config.moduleType.toUpperCase()} for ${sourceFiles.length} files...`, + ); + + const finalCompilerOptions = buildCompilerOptions( + tsconfigContent, + config.tsconfigPath, + config.outDir, + config.resolvePaths, + ); + + const program = ts.createProgram(sourceFiles, finalCompilerOptions); + const emitResult = + config.resolvePaths && config.resolvePathsBaseDir + ? await emitWithPathAliasResolution(program, config) + : emitStandard(program); + + if (!emitResult.success) { + throw new Error(ERROR_MESSAGES.COMPILATION_FAILED); + } + + logger.verbose(`✓ ${config.moduleType.toUpperCase()} build completed successfully`); + }, +}); 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 ca1dedf1af78..851e424443ab 100644 --- a/packages/nx-infra-plugin/src/executors/build-typescript/executor.ts +++ b/packages/nx-infra-plugin/src/executors/build-typescript/executor.ts @@ -1,294 +1 @@ -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, - toPosixPath, -} from '../../utils/path-resolver'; -import { isWindowsOS } from '../../utils/common'; -import { logError } from '../../utils/error-handler'; -import { exists, ensureDir, writeFileText } from '../../utils/file-operations'; - -type AliasTranspileFunc = (filePath: string, fileContents: string) => string; - -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 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(ERROR_MESSAGES.TSCONFIG_NOT_FOUND(tsconfigPath)); - } - - 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 = toPosixPath(path.join(projectRoot, srcPattern)); - - const resolvedExcludes = excludePatterns.map((pattern) => { - const result = path.join(projectRoot, pattern); - return toPosixPath(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 replacePathPrefix(filePath: string, from: string, to: string): string { - if (isWindowsOS()) { - return normalizeGlobPathForWindows(filePath).replace( - normalizeGlobPathForWindows(from), - normalizeGlobPathForWindows(to), - ); - } - - return filePath.replace(from, to); -} - -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) { - const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start!); - const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, NEWLINE_CHAR); - return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`; - } - return ts.flattenDiagnosticMessageText(diagnostic.messageText, NEWLINE_CHAR); - }); -} - -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 = replacePathPrefix(filePath, 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 }; -} - -async function emitWithPathAliasResolution( - program: ts.Program, - config: ResolvedConfig, -): Promise { - const aliasTranspileFunc = await createAliasTranspileFunc( - config.tsconfigPath, - config.resolvePathsBaseDir!, - ); - - logger.verbose(`Path alias resolution enabled with base dir: ${config.resolvePathsBaseDir}`); - - 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 { - validateOptions(config); - - const { content: tsconfigContent, compilerOptions } = await loadTsConfig(config.tsconfigPath); - compilerOptions.outDir = config.outDir; - await ensureDir(config.outDir); - - const sourceFiles = await resolveSourceFiles( - config.projectRoot, - config.srcPattern, - config.excludePatterns, - ); - - logger.verbose( - `Building ${config.moduleType.toUpperCase()} for ${sourceFiles.length} files...`, - ); - - const finalCompilerOptions = buildCompilerOptions( - tsconfigContent, - config.tsconfigPath, - config.outDir, - config.resolvePaths, - ); - - const program = ts.createProgram(sourceFiles, finalCompilerOptions); - const emitResult = - config.resolvePaths && config.resolvePathsBaseDir - ? await emitWithPathAliasResolution(program, config) - : emitStandard(program); - - if (!emitResult.success) { - return { success: false }; - } - - logger.verbose(`✓ ${config.moduleType.toUpperCase()} build completed successfully`); - return { success: true }; - } catch (error) { - logError(ERROR_MESSAGES.BUILD_FAILED(config.moduleType.toUpperCase()), error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './build-typescript.impl'; diff --git a/packages/nx-infra-plugin/src/executors/bundle/bundle.impl.ts b/packages/nx-infra-plugin/src/executors/bundle/bundle.impl.ts new file mode 100644 index 000000000000..ca3fd5b863ef --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/bundle/bundle.impl.ts @@ -0,0 +1,208 @@ +import { logger } from '@nx/devkit'; +import * as path from 'path'; +import * as fs from 'fs'; +import type { Configuration, Stats } from 'webpack'; +import { createExecutor } from '../../utils/create-executor'; +import { BundleExecutorSchema } from './schema'; + +const ERROR_MESSAGES = { + ENTRIES_EMPTY: 'entries must contain at least one entry point', + WEBPACK_NOT_FOUND: + 'webpack is not installed. Add webpack as a dependency to the consuming project.', + WEBPACK_CONFIG_NOT_FOUND: (configPath: string) => `Webpack config not found: ${configPath}`, + WEBPACK_CONFIG_LOAD_FAILED: (configPath: string, message: string) => + `Failed to load webpack config: ${configPath} - ${message}`, + WEBPACK_ERROR: (message: string) => `Webpack build failed: ${message}`, +} as const; + +function loadWebpack(): typeof import('webpack') { + try { + return require('webpack'); + } catch { + throw new Error(ERROR_MESSAGES.WEBPACK_NOT_FOUND); + } +} + +function buildEntryMap( + entries: string[], + sourceDir: string, + mode: 'debug' | 'production', +): Record { + const entryMap: Record = {}; + + for (const entry of entries) { + const baseName = path.basename(entry, path.extname(entry)); + const outputName = mode === 'debug' ? `${baseName}.debug` : baseName; + entryMap[outputName] = path.resolve(sourceDir, entry); + } + + return entryMap; +} + +function createWebpackConfig( + webpack: typeof import('webpack'), + baseConfig: Configuration, + entryMap: Record, + outDir: string, + mode: 'debug' | 'production', + projectRoot: string, + sourceMap: boolean, +): Configuration { + const config: Configuration = { + ...baseConfig, + context: projectRoot, + entry: entryMap, + output: { + ...(baseConfig.output || {}), + path: outDir, + filename: '[name].js', + }, + }; + + config.optimization = { + ...(config.optimization || {}), + minimize: false, + }; + + if (mode === 'debug') { + config.output = { + ...(config.output || {}), + pathinfo: true, + }; + if (sourceMap) { + config.devtool = 'eval-source-map'; + } + } + + const isInternalBuild = + String(process.env.BUILD_INTERNAL_PACKAGE).toLowerCase() === 'true' + || String(process.env.BUILD_TEST_INTERNAL_PACKAGE).toLowerCase() === 'true'; + + if (isInternalBuild) { + const plugins = config.plugins ? [...config.plugins] : []; + plugins.push( + new webpack.NormalModuleReplacementPlugin(/(.*)\/license_validation/, (resource) => { + resource.request = resource.request.replace( + 'license_validation', + 'license_validation_internal', + ); + }), + ); + config.plugins = plugins; + } + + return config; +} + +function runWebpack(webpack: typeof import('webpack'), config: Configuration): Promise { + return new Promise((resolve, reject) => { + webpack(config, (err, stats) => { + if (err) { + reject(err); + return; + } + if (!stats) { + reject(new Error('Webpack returned no stats')); + return; + } + if (stats.hasErrors()) { + const info = stats.toJson({ errors: true }); + const errorMessages = (info.errors || []).map((entry) => entry.message).join('\n'); + reject(new Error(errorMessages)); + return; + } + resolve(stats); + }); + }); +} + +function loadWebpackConfig(resolvedConfigPath: string): Configuration { + if (!fs.existsSync(resolvedConfigPath)) { + throw new Error(ERROR_MESSAGES.WEBPACK_CONFIG_NOT_FOUND(resolvedConfigPath)); + } + + try { + return require(resolvedConfigPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(ERROR_MESSAGES.WEBPACK_CONFIG_LOAD_FAILED(resolvedConfigPath, message)); + } +} + +interface ResolvedBundle { + projectRoot: string; + entries: string[]; + resolvedSourceDir: string; + resolvedOutDir: string; + mode: 'debug' | 'production'; + sourceMap: boolean; + webpack: typeof import('webpack'); + baseConfig: Configuration; +} + +export default createExecutor({ + name: 'Bundle', + resolve: (options, { projectRoot }) => { + const { + entries, + sourceDir, + outDir, + mode, + webpackConfigPath = './webpack.config.js', + sourceMap = true, + } = options; + + if (!entries?.length) { + throw new Error(ERROR_MESSAGES.ENTRIES_EMPTY); + } + + const resolvedSourceDir = path.resolve(projectRoot, sourceDir); + const resolvedOutDir = path.resolve(projectRoot, outDir); + const resolvedConfigPath = path.resolve(projectRoot, webpackConfigPath); + + const webpack = loadWebpack(); + const baseConfig = loadWebpackConfig(resolvedConfigPath); + + return { + projectRoot, + entries, + resolvedSourceDir, + resolvedOutDir, + mode, + sourceMap, + webpack, + baseConfig, + }; + }, + run: async (resolved) => { + const entryMap = buildEntryMap(resolved.entries, resolved.resolvedSourceDir, resolved.mode); + const config = createWebpackConfig( + resolved.webpack, + resolved.baseConfig, + entryMap, + resolved.resolvedOutDir, + resolved.mode, + resolved.projectRoot, + resolved.sourceMap, + ); + + logger.verbose(`Bundling ${resolved.entries.length} entries in ${resolved.mode} mode`); + logger.verbose(`Source: ${resolved.resolvedSourceDir}`); + logger.verbose(`Output: ${resolved.resolvedOutDir}`); + + try { + const stats = await runWebpack(resolved.webpack, config); + + if (stats.hasWarnings()) { + const info = stats.toJson({ warnings: true }); + (info.warnings || []).forEach((warning) => logger.warn(warning.message)); + } + + const assets = Object.keys(stats.compilation.assets); + logger.verbose(`Produced ${assets.length} bundle(s): ${assets.join(', ')}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(ERROR_MESSAGES.WEBPACK_ERROR(message)); + } + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/bundle/executor.ts b/packages/nx-infra-plugin/src/executors/bundle/executor.ts index ea91b572e0d8..bdbc58efae9a 100644 --- a/packages/nx-infra-plugin/src/executors/bundle/executor.ts +++ b/packages/nx-infra-plugin/src/executors/bundle/executor.ts @@ -1,194 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import * as fs from 'fs'; -import type { Configuration, Stats } from 'webpack'; -import { BundleExecutorSchema } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; - -const ERROR_MESSAGES = { - ENTRIES_EMPTY: 'entries must contain at least one entry point', - WEBPACK_NOT_FOUND: - 'webpack is not installed. Add webpack as a dependency to the consuming project.', - WEBPACK_CONFIG_NOT_FOUND: (configPath: string) => `Webpack config not found: ${configPath}`, - WEBPACK_ERROR: (msg: string) => `Webpack build failed: ${msg}`, -} as const; - -function loadWebpack(): typeof import('webpack') { - try { - return require('webpack'); - } catch { - throw new Error(ERROR_MESSAGES.WEBPACK_NOT_FOUND); - } -} - -function buildEntryMap( - entries: string[], - sourceDir: string, - mode: 'debug' | 'production', -): Record { - const entryMap: Record = {}; - - for (const entry of entries) { - const baseName = path.basename(entry, path.extname(entry)); - const outputName = mode === 'debug' ? `${baseName}.debug` : baseName; - entryMap[outputName] = path.resolve(sourceDir, entry); - } - - return entryMap; -} - -function createWebpackConfig( - webpack: typeof import('webpack'), - baseConfig: Configuration, - entryMap: Record, - outDir: string, - mode: 'debug' | 'production', - projectRoot: string, - sourceMap: boolean, -): Configuration { - const config: Configuration = { - ...baseConfig, - context: projectRoot, - entry: entryMap, - output: { - ...(baseConfig.output || {}), - path: outDir, - filename: '[name].js', - }, - }; - - config.optimization = { - ...(config.optimization || {}), - minimize: false, - }; - - if (mode === 'debug') { - config.output = { - ...(config.output || {}), - pathinfo: true, - }; - if (sourceMap) { - config.devtool = 'eval-source-map'; - } - } - - const isInternalBuild = - String(process.env.BUILD_INTERNAL_PACKAGE).toLowerCase() === 'true' - || String(process.env.BUILD_TEST_INTERNAL_PACKAGE).toLowerCase() === 'true'; - - if (isInternalBuild) { - const plugins = config.plugins ? [...config.plugins] : []; - plugins.push( - new webpack.NormalModuleReplacementPlugin(/(.*)\/license_validation/, (resource) => { - resource.request = resource.request.replace( - 'license_validation', - 'license_validation_internal', - ); - }), - ); - config.plugins = plugins; - } - - return config; -} - -function runWebpack(webpack: typeof import('webpack'), config: Configuration): Promise { - return new Promise((resolve, reject) => { - webpack(config, (err, stats) => { - if (err) { - reject(err); - return; - } - if (!stats) { - reject(new Error('Webpack returned no stats')); - return; - } - if (stats.hasErrors()) { - const info = stats.toJson({ errors: true }); - const errorMessages = (info.errors || []).map((e) => e.message).join('\n'); - reject(new Error(errorMessages)); - return; - } - resolve(stats); - }); - }); -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - const { - entries, - sourceDir, - outDir, - mode, - webpackConfigPath = './webpack.config.js', - sourceMap = true, - } = options; - - if (!entries?.length) { - logger.error(ERROR_MESSAGES.ENTRIES_EMPTY); - return { success: false }; - } - - const resolvedSourceDir = path.resolve(projectRoot, sourceDir); - const resolvedOutDir = path.resolve(projectRoot, outDir); - const resolvedConfigPath = path.resolve(projectRoot, webpackConfigPath); - - let webpack: typeof import('webpack'); - try { - webpack = loadWebpack(); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - logger.error(errorMsg); - return { success: false }; - } - - if (!fs.existsSync(resolvedConfigPath)) { - logger.error(ERROR_MESSAGES.WEBPACK_CONFIG_NOT_FOUND(resolvedConfigPath)); - return { success: false }; - } - - let baseConfig: Configuration; - try { - baseConfig = require(resolvedConfigPath); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - logger.error(`Failed to load webpack config: ${resolvedConfigPath} - ${errorMsg}`); - return { success: false }; - } - - const entryMap = buildEntryMap(entries, resolvedSourceDir, mode); - const config = createWebpackConfig( - webpack, - baseConfig, - entryMap, - resolvedOutDir, - mode, - projectRoot, - sourceMap, - ); - - logger.verbose(`Bundling ${entries.length} entries in ${mode} mode`); - logger.verbose(`Source: ${resolvedSourceDir}`); - logger.verbose(`Output: ${resolvedOutDir}`); - - try { - const stats = await runWebpack(webpack, config); - - if (stats.hasWarnings()) { - const info = stats.toJson({ warnings: true }); - (info.warnings || []).forEach((w) => logger.warn(w.message)); - } - - const assets = Object.keys(stats.compilation.assets); - logger.verbose(`Produced ${assets.length} bundle(s): ${assets.join(', ')}`); - - return { success: true }; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - logger.error(ERROR_MESSAGES.WEBPACK_ERROR(errorMsg)); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './bundle.impl'; diff --git a/packages/nx-infra-plugin/src/executors/clean/clean.impl.ts b/packages/nx-infra-plugin/src/executors/clean/clean.impl.ts new file mode 100644 index 000000000000..1025dcdb684f --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/clean/clean.impl.ts @@ -0,0 +1,135 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { glob } from 'glob'; +import rimraf from 'rimraf'; +import { promisify } from 'util'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { CleanExecutorSchema } from './schema'; + +const DEFAULT_TARGET_DIR = './src'; +const rimrafAsync = promisify(rimraf); + +function resolveExcludePatterns(patterns: string[], baseDir: string): string[] { + return patterns.map((pattern) => path.resolve(baseDir, pattern)); +} + +function isPathExcluded(filePath: string, excludePaths: string[]): boolean { + const normalized = path.normalize(filePath); + + return excludePaths.some((excludePath) => { + const normalizedExclude = path.normalize(excludePath); + + if (normalized === normalizedExclude) { + return true; + } + + return normalized.startsWith(normalizedExclude + path.sep); + }); +} + +async function generateDeletionPlan(targetDir: string, excludePaths: string[]): Promise { + if (!fs.existsSync(targetDir)) { + return []; + } + + if (excludePaths.length === 0) { + return [targetDir]; + } + + const allPaths = await glob('**/*', { + cwd: targetDir, + dot: true, + nodir: false, + }); + + const fullPaths = allPaths.map((relativePath) => path.join(targetDir, relativePath)); + + return fullPaths.filter((fullPath) => !isPathExcluded(fullPath, excludePaths)); +} + +async function removeDirectoryCompletely(targetDirectory: string): Promise { + if (fs.existsSync(targetDirectory)) { + await rimrafAsync(targetDirectory); + } +} + +async function removeDirectoryWithExclusions( + targetDirectory: string, + excludePaths: string[], +): Promise { + if (!fs.existsSync(targetDirectory)) { + return; + } + + if (excludePaths.length === 0) { + await rimrafAsync(path.join(targetDirectory, '*')); + await rimrafAsync(path.join(targetDirectory, '.*')); + return; + } + + const itemsToDelete = await generateDeletionPlan(targetDirectory, excludePaths); + + const sortedItems = itemsToDelete.sort((a, b) => { + const aDepth = a.split(path.sep).length; + const bDepth = b.split(path.sep).length; + return aDepth - bDepth; + }); + + for (const item of sortedItems) { + if (fs.existsSync(item)) { + const containsExcluded = excludePaths.some( + (excludePath) => excludePath.startsWith(item + path.sep) || excludePath === item, + ); + + if (!containsExcluded) { + await rimrafAsync(item); + } + } + } +} + +interface ResolvedClean { + targetDirectory: string; + excludePatterns: string[]; + absoluteExcludePaths: string[]; + projectRoot: string; +} + +export default createExecutor({ + name: 'Clean', + resolve: (options, { projectRoot }) => { + const targetDirectory = path.join(projectRoot, options.targetDirectory || DEFAULT_TARGET_DIR); + const excludePatterns = options.excludePatterns || []; + const absoluteExcludePaths = resolveExcludePatterns(excludePatterns, projectRoot); + return { targetDirectory, excludePatterns, absoluteExcludePaths, projectRoot }; + }, + run: async (resolved) => { + const { targetDirectory, excludePatterns, absoluteExcludePaths } = resolved; + + logger.verbose( + `Cleaning ${targetDirectory}${excludePatterns.length > 0 ? ` with ${excludePatterns.length} exclusions` : ' completely'}...`, + ); + + if (excludePatterns.length > 0) { + logger.verbose(`Excluding patterns: ${excludePatterns.join(', ')}`); + } + + if (excludePatterns.length === 0) { + await removeDirectoryCompletely(targetDirectory); + logger.verbose(`Removed directory: ${targetDirectory}`); + return; + } + + if (!fs.existsSync(targetDirectory)) { + logger.verbose(`Directory does not exist: ${targetDirectory}`); + return; + } + + await removeDirectoryWithExclusions(targetDirectory, absoluteExcludePaths); + + logger.verbose( + `Cleaned directory: ${targetDirectory} with ${absoluteExcludePaths.length} exclusions preserved`, + ); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/clean/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/clean/executor.e2e.spec.ts index b300a1bea3bb..0821008e6ac2 100644 --- a/packages/nx-infra-plugin/src/executors/clean/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/clean/executor.e2e.spec.ts @@ -31,15 +31,10 @@ describe('CleanExecutor E2E', () => { fs.mkdirSync(npmDir, { recursive: true }); await writeFileText(path.join(npmDir, 'index.js'), 'export const foo = "bar";'); - await writeFileText(path.join(npmDir, 'package.json'), '{"name": "test"}'); - await writeFileText(path.join(npmDir, 'README.md'), '# Test'); fs.mkdirSync(path.join(npmDir, 'esm'), { recursive: true }); await writeFileText(path.join(npmDir, 'esm', 'index.js'), 'export * from "./foo";'); - fs.mkdirSync(path.join(npmDir, 'cjs'), { recursive: true }); - await writeFileText(path.join(npmDir, 'cjs', 'index.js'), 'module.exports = {};'); - fs.mkdirSync(path.join(npmDir, 'components', 'button'), { recursive: true }); await writeFileText( path.join(npmDir, 'components', 'button', 'index.js'), @@ -47,35 +42,13 @@ describe('CleanExecutor E2E', () => { ); }); - it('should delete the entire directory', async () => { + it('should delete the entire directory recursively', async () => { const options: CleanExecutorSchema = { targetDirectory: './npm', }; const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); - expect(fs.existsSync(npmDir)).toBe(true); - expect(fs.existsSync(path.join(npmDir, 'index.js'))).toBe(true); - - const result = await executor(options, context); - - expect(result.success).toBe(true); - - expect(fs.existsSync(npmDir)).toBe(false); - }); - - it('should delete all files and subdirectories recursively', async () => { - const options: CleanExecutorSchema = { - targetDirectory: './npm', - }; - - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const npmDir = path.join(projectDir, 'npm'); - - expect(fs.existsSync(path.join(npmDir, 'esm', 'index.js'))).toBe(true); - expect(fs.existsSync(path.join(npmDir, 'cjs', 'index.js'))).toBe(true); - expect(fs.existsSync(path.join(npmDir, 'components', 'button', 'index.js'))).toBe(true); - const result = await executor(options, context); expect(result.success).toBe(true); @@ -98,7 +71,7 @@ describe('CleanExecutor E2E', () => { expect(result.success).toBe(true); }); - it('should not affect other directories', async () => { + it('should not affect sibling directories outside target', async () => { const projectDir = path.join(tempDir, 'packages', 'test-lib'); const srcDir = path.join(projectDir, 'src'); @@ -118,25 +91,9 @@ describe('CleanExecutor E2E', () => { expect(result.success).toBe(true); expect(fs.existsSync(path.join(projectDir, 'npm'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'index.ts'))).toBe(true); expect(fs.existsSync(path.join(distDir, 'output.js'))).toBe(true); }); - - it('should use simple mode by default', async () => { - const options: CleanExecutorSchema = { - targetDirectory: './npm', - }; - - const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); - - expect(fs.existsSync(npmDir)).toBe(true); - - const result = await executor(options, context); - - expect(result.success).toBe(true); - expect(fs.existsSync(npmDir)).toBe(false); - }); }); describe('Selective cleaning (with exclusions)', () => { @@ -149,13 +106,12 @@ describe('CleanExecutor E2E', () => { fs.mkdirSync(path.join(srcDir, 'core'), { recursive: true }); await writeFileText(path.join(srcDir, 'core', 'component.tsx'), 'export class Component {}'); - await writeFileText(path.join(srcDir, 'core', 'config.tsx'), 'export class Config {}'); fs.mkdirSync(path.join(srcDir, 'data'), { recursive: true }); await writeFileText(path.join(srcDir, 'data', 'grid.tsx'), 'export const Grid = () => {};'); }); - it('should clean all files in target directory', async () => { + it('should clean non-excluded files and preserve excluded directory contents', async () => { const options: CleanExecutorSchema = { targetDirectory: './src', excludePatterns: ['./src/core'], @@ -163,10 +119,6 @@ describe('CleanExecutor E2E', () => { const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); - expect(fs.existsSync(path.join(srcDir, 'button.tsx'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'text-box.tsx'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'index.ts'))).toBe(true); - const result = await executor(options, context); expect(result.success).toBe(true); @@ -174,40 +126,9 @@ describe('CleanExecutor E2E', () => { expect(fs.existsSync(path.join(srcDir, 'button.tsx'))).toBe(false); expect(fs.existsSync(path.join(srcDir, 'text-box.tsx'))).toBe(false); expect(fs.existsSync(path.join(srcDir, 'index.ts'))).toBe(false); - }); - - it('should preserve excluded directories', async () => { - const options: CleanExecutorSchema = { - targetDirectory: './src', - excludePatterns: ['./src/core'], - }; - - const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); - - const result = await executor(options, context); - - expect(result.success).toBe(true); + expect(fs.existsSync(path.join(srcDir, 'data'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'core'))).toBe(true); expect(fs.existsSync(path.join(srcDir, 'core', 'component.tsx'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'core', 'config.tsx'))).toBe(true); - }); - - it('should clean nested directories', async () => { - const options: CleanExecutorSchema = { - targetDirectory: './src', - excludePatterns: ['./src/core'], - }; - - const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); - - expect(fs.existsSync(path.join(srcDir, 'data', 'grid.tsx'))).toBe(true); - - const result = await executor(options, context); - - expect(result.success).toBe(true); - - expect(fs.existsSync(path.join(srcDir, 'data'))).toBe(false); }); it('should preserve multiple excluded directories', async () => { @@ -236,7 +157,7 @@ describe('CleanExecutor E2E', () => { expect(fs.existsSync(path.join(srcDir, 'data'))).toBe(false); }); - it('should handle nested exclude patterns', async () => { + it('should preserve descendants of excluded directories', async () => { const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); fs.mkdirSync(path.join(srcDir, 'core', 'internal'), { recursive: true }); @@ -257,21 +178,6 @@ describe('CleanExecutor E2E', () => { expect(fs.existsSync(path.join(srcDir, 'core', 'internal', 'impl.tsx'))).toBe(true); }); - it('should clean all files when no exclude patterns specified', async () => { - const options: CleanExecutorSchema = { - targetDirectory: './src', - }; - - const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); - - const result = await executor(options, context); - - expect(result.success).toBe(true); - - expect(fs.existsSync(path.join(srcDir, 'button.tsx'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'core'))).toBe(false); - }); - it('should handle absolute exclude paths', async () => { const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); const absoluteCorePath = path.join(srcDir, 'core'); @@ -318,152 +224,31 @@ describe('CleanExecutor E2E', () => { expect(fs.existsSync(path.join(srcDir, 'ui', 'popup', 'index.ts'))).toBe(true); expect(fs.existsSync(path.join(srcDir, 'ui', 'popup', 'service', 'test.ts'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'ui', 'popup', 'service'))).toBe(true); expect(fs.existsSync(path.join(srcDir, 'ui', 'popup'))).toBe(true); expect(fs.existsSync(path.join(srcDir, 'ui', 'button'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'ui', 'button', 'index.ts'))).toBe(false); }); - }); - describe('Selective cleaning (shallow-style)', () => { - beforeEach(async () => { + it('should preserve only first-level items when top-level dirs are excluded', async () => { const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); - await writeFileText(path.join(srcDir, 'button.tsx'), 'export const Button = () => {};'); - await writeFileText(path.join(srcDir, 'text-box.tsx'), 'export const TextBox = () => {};'); - await writeFileText(path.join(srcDir, 'index.ts'), 'export * from "./button";'); - - fs.mkdirSync(path.join(srcDir, 'core'), { recursive: true }); - await writeFileText(path.join(srcDir, 'core', 'component.tsx'), 'export class Component {}'); - await writeFileText(path.join(srcDir, 'core', 'config.tsx'), 'export class Config {}'); - fs.mkdirSync(path.join(srcDir, 'common'), { recursive: true }); await writeFileText(path.join(srcDir, 'common', 'utils.ts'), 'export const utils = {};'); - fs.mkdirSync(path.join(srcDir, 'data'), { recursive: true }); - await writeFileText(path.join(srcDir, 'data', 'grid.tsx'), 'export const Grid = () => {};'); - }); - - it('should remove only first-level items', async () => { const options: CleanExecutorSchema = { targetDirectory: './src', excludePatterns: ['./src/core', './src/common'], }; - const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); - - expect(fs.existsSync(path.join(srcDir, 'button.tsx'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'text-box.tsx'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'index.ts'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'data'))).toBe(true); - - const result = await executor(options, context); - - expect(result.success).toBe(true); - - expect(fs.existsSync(path.join(srcDir, 'button.tsx'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'text-box.tsx'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'index.ts'))).toBe(false); - - expect(fs.existsSync(path.join(srcDir, 'data'))).toBe(false); - - expect(fs.existsSync(path.join(srcDir, 'core'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'core', 'component.tsx'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'core', 'config.tsx'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'common'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'common', 'utils.ts'))).toBe(true); - }); - - it('should preserve specific files at root level', async () => { - const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); - const indexPath = path.join(srcDir, 'index.ts'); - - const options: CleanExecutorSchema = { - targetDirectory: './src', - excludePatterns: ['./src/core', './src/common', indexPath], - }; - - const result = await executor(options, context); - - expect(result.success).toBe(true); - - expect(fs.existsSync(indexPath)).toBe(true); - - expect(fs.existsSync(path.join(srcDir, 'button.tsx'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'text-box.tsx'))).toBe(false); - - expect(fs.existsSync(path.join(srcDir, 'core'))).toBe(true); - expect(fs.existsSync(path.join(srcDir, 'common'))).toBe(true); - }); - - it('should handle non-existent directory', async () => { - const options: CleanExecutorSchema = { - targetDirectory: './nonexistent', - excludePatterns: [], - }; - - const result = await executor(options, context); - - expect(result.success).toBe(true); - }); - - it('should remove all items when no exclusions', async () => { - const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); - - const options: CleanExecutorSchema = { - targetDirectory: './src', - }; - const result = await executor(options, context); expect(result.success).toBe(true); expect(fs.existsSync(path.join(srcDir, 'button.tsx'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'core'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'common'))).toBe(false); expect(fs.existsSync(path.join(srcDir, 'data'))).toBe(false); - }); - - it('should handle relative exclude paths', async () => { - const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); - - const options: CleanExecutorSchema = { - targetDirectory: './src', - excludePatterns: ['./src/core', './src/common'], - }; - - const result = await executor(options, context); - - expect(result.success).toBe(true); expect(fs.existsSync(path.join(srcDir, 'core', 'component.tsx'))).toBe(true); expect(fs.existsSync(path.join(srcDir, 'common', 'utils.ts'))).toBe(true); }); - - it('should handle absolute path exclusions', async () => { - const srcDir = path.join(tempDir, 'packages', 'test-lib', 'src'); - - const coreDir = path.join(srcDir, 'core'); - const commonDir = path.join(srcDir, 'common'); - const indexFile = path.join(srcDir, 'index.ts'); - - const options: CleanExecutorSchema = { - targetDirectory: './src', - excludePatterns: [coreDir, commonDir, indexFile], - }; - - const result = await executor(options, context); - - expect(result.success).toBe(true); - - expect(fs.existsSync(coreDir)).toBe(true); - expect(fs.existsSync(commonDir)).toBe(true); - expect(fs.existsSync(indexFile)).toBe(true); - - expect(fs.existsSync(path.join(srcDir, 'button.tsx'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'text-box.tsx'))).toBe(false); - expect(fs.existsSync(path.join(srcDir, 'data'))).toBe(false); - }); }); }); diff --git a/packages/nx-infra-plugin/src/executors/clean/executor.ts b/packages/nx-infra-plugin/src/executors/clean/executor.ts index f207c6a3d66f..9452f56387d4 100644 --- a/packages/nx-infra-plugin/src/executors/clean/executor.ts +++ b/packages/nx-infra-plugin/src/executors/clean/executor.ts @@ -1,135 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as fs from 'fs-extra'; -import * as path from 'path'; -import { glob } from 'glob'; -import rimraf from 'rimraf'; -import { promisify } from 'util'; -import { CleanExecutorSchema } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; - -const DEFAULT_TARGET_DIR = './src'; -const rimrafAsync = promisify(rimraf); - -function resolveExcludePatterns(patterns: string[], baseDir: string): string[] { - return patterns.map((pattern) => path.resolve(baseDir, pattern)); -} - -function isPathExcluded(filePath: string, excludePaths: string[]): boolean { - const normalized = path.normalize(filePath); - - return excludePaths.some((excludePath) => { - const normalizedExclude = path.normalize(excludePath); - - if (normalized === normalizedExclude) { - return true; - } - - return normalized.startsWith(normalizedExclude + path.sep); - }); -} - -async function generateDeletionPlan(targetDir: string, excludePaths: string[]): Promise { - if (!fs.existsSync(targetDir)) { - return []; - } - - if (excludePaths.length === 0) { - return [targetDir]; - } - - const allPaths = await glob('**/*', { - cwd: targetDir, - dot: true, - nodir: false, - }); - - const fullPaths = allPaths.map((relativePath) => path.join(targetDir, relativePath)); - - return fullPaths.filter((fullPath) => !isPathExcluded(fullPath, excludePaths)); -} - -async function removeDirectoryCompletely(targetDirectory: string): Promise { - if (fs.existsSync(targetDirectory)) { - await rimrafAsync(targetDirectory); - } -} - -async function removeDirectoryWithExclusions( - targetDirectory: string, - excludePaths: string[], -): Promise { - if (!fs.existsSync(targetDirectory)) { - return; - } - - if (excludePaths.length === 0) { - await rimrafAsync(path.join(targetDirectory, '*')); - await rimrafAsync(path.join(targetDirectory, '.*')); - return; - } - - const itemsToDelete = await generateDeletionPlan(targetDirectory, excludePaths); - - const sortedItems = itemsToDelete.sort((a, b) => { - const aDepth = a.split(path.sep).length; - const bDepth = b.split(path.sep).length; - return aDepth - bDepth; - }); - - for (const item of sortedItems) { - if (fs.existsSync(item)) { - const containsExcluded = excludePaths.some( - (excludePath) => excludePath.startsWith(item + path.sep) || excludePath === item, - ); - - if (!containsExcluded) { - await rimrafAsync(item); - } - } - } -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const absoluteProjectRoot = resolveProjectPath(context); - const targetDirectory = path.join( - absoluteProjectRoot, - options.targetDirectory || DEFAULT_TARGET_DIR, - ); - const excludePatterns = options.excludePatterns || []; - - logger.verbose( - `Cleaning ${targetDirectory}${excludePatterns.length > 0 ? ` with ${excludePatterns.length} exclusions` : ' completely'}...`, - ); - - if (excludePatterns.length > 0) { - logger.verbose(`Excluding patterns: ${excludePatterns.join(', ')}`); - } - - try { - const absoluteExcludePaths = resolveExcludePatterns(excludePatterns, absoluteProjectRoot); - - if (excludePatterns.length === 0) { - await removeDirectoryCompletely(targetDirectory); - logger.verbose(`Removed directory: ${targetDirectory}`); - } else { - if (!fs.existsSync(targetDirectory)) { - logger.verbose(`Directory does not exist: ${targetDirectory}`); - return { success: true }; - } - - await removeDirectoryWithExclusions(targetDirectory, absoluteExcludePaths); - - logger.verbose( - `Cleaned directory: ${targetDirectory} with ${absoluteExcludePaths.length} exclusions preserved`, - ); - } - - return { success: true }; - } catch (error) { - logError(`Failed to clean ${targetDirectory}`, error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './clean.impl'; diff --git a/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts b/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts new file mode 100644 index 000000000000..1278767fef36 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts @@ -0,0 +1,161 @@ +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import * as terser from 'terser'; +import { js as jsBeautify } from 'js-beautify'; +import { createExecutor } from '../../utils/create-executor'; +import { expandEntries } from '../../utils/glob-discovery'; +import { + ensureTrailingNewline, + normalizeEol, + readFileText, + writeFileText, +} from '../../utils/file-operations'; +import { CompressExecutorSchema, CompressMode, CompressModeName } from './schema'; + +const STRIP_DEBUG_REGEX = /\/{2,}\s{0,}#DEBUG[\s\S]*?\/{2,}\s{0,}#ENDDEBUG/g; + +export function stripDebug(content: string): string { + return content.replace(STRIP_DEBUG_REGEX, ''); +} + +const ERROR_TERSER_MINIFY_NO_OUTPUT = 'Terser minification produced no output'; +const ERROR_TERSER_BEAUTIFY_NO_OUTPUT = 'Terser beautification produced no output'; +const ERROR_UNKNOWN_MODE = (name: string) => `Unknown compress mode: ${name}`; + +const LICENSE_PREFIX_BANG = '!'; +const LICENSE_PREFIX_SPACE_BANG = ' !'; + +function createCommentFilter(eulaUrl?: string) { + return function saveLicenseComments(_node: unknown, comment: { value: string }): boolean { + return ( + comment.value.charAt(0) === LICENSE_PREFIX_BANG + || comment.value.startsWith(LICENSE_PREFIX_SPACE_BANG) + || (!!eulaUrl && comment.value.indexOf(eulaUrl) > -1) + ); + }; +} + +async function runMinify(content: string, eulaUrl?: string): Promise { + const result = await terser.minify(content, { + output: { + ascii_only: true, + comments: createCommentFilter(eulaUrl), + }, + }); + + if (result.code == null) { + throw new Error(ERROR_TERSER_MINIFY_NO_OUTPUT); + } + + return result.code; +} + +async function runBeautify(content: string, eulaUrl?: string): Promise { + const uglifyResult = await terser.minify(content, { + mangle: false, + compress: { + sequences: false, + properties: true, + dead_code: true, + drop_debugger: true, + unsafe: false, + conditionals: false, + comparisons: false, + evaluate: true, + booleans: false, + loops: false, + unused: true, + hoist_funs: false, + hoist_vars: false, + if_return: false, + join_vars: false, + collapse_vars: false, + side_effects: false, + global_defs: {}, + }, + output: { + braces: true, + ascii_only: true, + comments: createCommentFilter(eulaUrl), + }, + }); + + if (uglifyResult.code == null) { + throw new Error(ERROR_TERSER_BEAUTIFY_NO_OUTPUT); + } + + return jsBeautify(uglifyResult.code); +} + +function normalizeOutput(content: string, trailingNewline: boolean): string { + const normalized = normalizeEol(content); + return trailingNewline ? ensureTrailingNewline(normalized) : normalized; +} + +interface ResolvedMode { + name: CompressModeName; + eulaUrl?: string; + trailingNewline: boolean; +} + +function resolveMode(mode: CompressMode): ResolvedMode { + if (typeof mode === 'string') { + return { name: mode, trailingNewline: true }; + } + const trailingNewline = mode.trailingNewline ?? true; + if (mode.name === 'minify' || mode.name === 'beautify') { + return { name: mode.name, eulaUrl: mode.eulaUrl, trailingNewline }; + } + return { name: mode.name, trailingNewline }; +} + +type CompressStrategy = (content: string, mode: ResolvedMode) => Promise; + +const STRATEGIES: Record = { + minify: async (content, { eulaUrl, trailingNewline }) => + normalizeOutput(await runMinify(stripDebug(content), eulaUrl), trailingNewline), + beautify: async (content, { eulaUrl, trailingNewline }) => + normalizeOutput(await runBeautify(content, eulaUrl), trailingNewline), + 'strip-debug': async (content) => stripDebug(content), + normalize: async (content, { trailingNewline }) => normalizeOutput(content, trailingNewline), +}; + +async function compressFile(filePath: string, mode: CompressMode): Promise { + const resolved = resolveMode(mode); + const runStrategy = STRATEGIES[resolved.name]; + if (!runStrategy) { + throw new Error(ERROR_UNKNOWN_MODE(resolved.name)); + } + + const raw = await readFileText(filePath); + await writeFileText(filePath, await runStrategy(raw, resolved)); +} + +interface ResolvedCompress { + projectRoot: string; + files: string[]; + mode: CompressMode; + modeName: string; +} + +export default createExecutor({ + name: 'Compress', + resolve: async (options, { projectRoot }) => { + const expanded = await expandEntries(options.files, { + projectRoot, + excludePatterns: options.exclude, + }); + return { + projectRoot, + files: expanded, + mode: options.mode, + modeName: typeof options.mode === 'string' ? options.mode : options.mode.name, + }; + }, + run: async ({ projectRoot, files, mode, modeName }) => { + for (const filePath of files) { + await compressFile(filePath, mode); + logger.verbose(`Compressed ${path.relative(projectRoot, filePath)} (${modeName})`); + } + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/compress/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/compress/executor.e2e.spec.ts index d147f20a27d4..7804df8100a8 100644 --- a/packages/nx-infra-plugin/src/executors/compress/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/compress/executor.e2e.spec.ts @@ -40,7 +40,7 @@ describe('CompressExecutor E2E', () => { cleanupTempDir(tempDir); }); - it('should minify and strip debug blocks', async () => { + it('should minify and preserve license comments', async () => { const filePath = path.join(projectDir, 'test.js'); await writeFileText(filePath, SAMPLE_CODE); @@ -57,9 +57,6 @@ describe('CompressExecutor E2E', () => { expect(output).toContain('/*!'); expect(output).toContain('DevExtreme'); - expect(output).not.toContain('debug only'); - expect(output).not.toContain('#DEBUG'); - expect(output.length).toBeLessThan(SAMPLE_CODE.length); }); @@ -86,7 +83,7 @@ describe('CompressExecutor E2E', () => { expect(output).toContain('debug only'); }); - it('should beautify with selective compression and preserve license comments', async () => { + it('should beautify with selective compression, preserve license comments, and preserve debug blocks', async () => { const filePath = path.join(projectDir, 'test.js'); await writeFileText(filePath, SAMPLE_CODE); @@ -105,21 +102,6 @@ describe('CompressExecutor E2E', () => { expect(output.split('\n').length).toBeGreaterThan(5); expect(output).not.toContain('unused'); - }); - - it('should preserve debug blocks in beautify mode', async () => { - const filePath = path.join(projectDir, 'test.js'); - await writeFileText(filePath, SAMPLE_CODE); - - const options: CompressExecutorSchema = { - files: ['./test.js'], - mode: 'beautify', - }; - - const result = await executor(options, context); - expect(result.success).toBe(true); - - const output = await readFileText(filePath); expect(output).toContain('debug only'); }); @@ -201,23 +183,6 @@ describe('CompressExecutor E2E', () => { expect(output.endsWith('\n')).toBe(false); }); - it('should skip trailing newline in minify mode (compress:bundles:prod -c production parity)', async () => { - const filePath = path.join(projectDir, 'test.js'); - await writeFileText(filePath, SAMPLE_CODE); - - const options: CompressExecutorSchema = { - files: ['./test.js'], - mode: { name: 'minify', trailingNewline: false }, - }; - - const result = await executor(options, context); - expect(result.success).toBe(true); - - const output = await readFileText(filePath); - expect(output.endsWith('\n')).toBe(false); - expect(output).not.toContain('debug only'); - }); - it('should respect exclude patterns and leave excluded files untouched', async () => { await writeFileText(path.join(projectDir, 'keep.js'), SAMPLE_CODE); await writeFileText(path.join(projectDir, 'skip.js'), SAMPLE_CODE); diff --git a/packages/nx-infra-plugin/src/executors/compress/executor.ts b/packages/nx-infra-plugin/src/executors/compress/executor.ts index 174bda2fa5a2..355157558de1 100644 --- a/packages/nx-infra-plugin/src/executors/compress/executor.ts +++ b/packages/nx-infra-plugin/src/executors/compress/executor.ts @@ -1,170 +1,2 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import * as terser from 'terser'; -import { js as jsBeautify } from 'js-beautify'; -import { glob } from 'glob'; -import { minimatch } from 'minimatch'; -import { CompressExecutorSchema, CompressMode, CompressModeName } from './schema'; -import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; -import { containsGlobPattern } from '../../utils/common'; -import { - readFileText, - writeFileText, - normalizeEol, - ensureTrailingNewline, -} from '../../utils/file-operations'; -import { stripDebug } from '../../utils/debug-strip'; - -function createCommentFilter(eulaUrl?: string) { - return function saveLicenseComments(_node: unknown, comment: { value: string }): boolean { - return ( - comment.value.charAt(0) === '!' - || comment.value.startsWith(' !') - || (!!eulaUrl && comment.value.indexOf(eulaUrl) > -1) - ); - }; -} - -async function runMinify(content: string, eulaUrl?: string): Promise { - const result = await terser.minify(content, { - output: { - ascii_only: true, - comments: createCommentFilter(eulaUrl), - }, - }); - - if (result.code == null) { - throw new Error('Terser minification produced no output'); - } - - return result.code; -} - -async function runBeautify(content: string, eulaUrl?: string): Promise { - const uglifyResult = await terser.minify(content, { - mangle: false, - compress: { - sequences: false, - properties: true, - dead_code: true, - drop_debugger: true, - unsafe: false, - conditionals: false, - comparisons: false, - evaluate: true, - booleans: false, - loops: false, - unused: true, - hoist_funs: false, - hoist_vars: false, - if_return: false, - join_vars: false, - collapse_vars: false, - side_effects: false, - global_defs: {}, - }, - output: { - braces: true, - ascii_only: true, - comments: createCommentFilter(eulaUrl), - }, - }); - - if (uglifyResult.code == null) { - throw new Error('Terser beautification produced no output'); - } - - return jsBeautify(uglifyResult.code); -} - -function normalizeOutput(content: string, trailingNewline: boolean): string { - const normalized = normalizeEol(content); - return trailingNewline ? ensureTrailingNewline(normalized) : normalized; -} - -type ResolvedMode = { - name: CompressModeName; - eulaUrl?: string; - trailingNewline: boolean; -}; - -function resolveMode(mode: CompressMode): ResolvedMode { - if (typeof mode === 'string') { - return { name: mode, trailingNewline: true }; - } - const trailingNewline = mode.trailingNewline ?? true; - if (mode.name === 'minify' || mode.name === 'beautify') { - return { name: mode.name, eulaUrl: mode.eulaUrl, trailingNewline }; - } - return { name: mode.name, trailingNewline }; -} - -type CompressStrategy = (content: string, mode: ResolvedMode) => Promise; - -const STRATEGIES: Record = { - minify: async (content, { eulaUrl, trailingNewline }) => - normalizeOutput(await runMinify(stripDebug(content), eulaUrl), trailingNewline), - beautify: async (content, { eulaUrl, trailingNewline }) => - normalizeOutput(await runBeautify(content, eulaUrl), trailingNewline), - 'strip-debug': async (content) => stripDebug(content), - normalize: async (content, { trailingNewline }) => normalizeOutput(content, trailingNewline), -}; - -async function compressFile(filePath: string, mode: CompressMode): Promise { - const resolved = resolveMode(mode); - const runStrategy = STRATEGIES[resolved.name]; - if (!runStrategy) { - throw new Error(`Unknown compress mode: ${resolved.name}`); - } - - const raw = await readFileText(filePath); - await writeFileText(filePath, await runStrategy(raw, resolved)); -} - -async function expandFileList( - files: string[], - exclude: string[] | undefined, - projectRoot: string, -): Promise { - const ignorePatterns = exclude?.map((p) => toPosixPath(path.resolve(projectRoot, p))); - - const isExcluded = (absolutePath: string): boolean => - !!ignorePatterns?.some((pattern) => - minimatch(toPosixPath(absolutePath), pattern, { dot: true }), - ); - - const resolved: string[] = []; - for (const entry of files) { - const absolute = path.resolve(projectRoot, entry); - if (containsGlobPattern(entry)) { - const pattern = toPosixPath(absolute); - const matches = await glob(pattern, { nodir: true, ignore: ignorePatterns }); - resolved.push(...matches); - } else if (!isExcluded(absolute)) { - resolved.push(absolute); - } - } - return [...new Set(resolved)]; -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - const { files, mode, exclude } = options; - const modeName = typeof mode === 'string' ? mode : mode.name; - - try { - const expanded = await expandFileList(files, exclude, projectRoot); - for (const filePath of expanded) { - await compressFile(filePath, mode); - logger.verbose(`Compressed ${path.relative(projectRoot, filePath)} (${modeName})`); - } - - return { success: true }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logger.error(`Compress failed: ${msg}`); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './compress.impl'; +export { stripDebug } from './compress.impl'; diff --git a/packages/nx-infra-plugin/src/executors/concatenate-files/concatenate-files.impl.ts b/packages/nx-infra-plugin/src/executors/concatenate-files/concatenate-files.impl.ts new file mode 100644 index 000000000000..425cf52c2e3e --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/concatenate-files/concatenate-files.impl.ts @@ -0,0 +1,176 @@ +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import { glob } from 'glob'; +import { createExecutor } from '../../utils/create-executor'; +import { toPosixPath } from '../../utils/path-resolver'; +import { containsGlobPattern } from '../../utils/common'; +import { exists, normalizeEol, readFileText, writeFileText } from '../../utils/file-operations'; +import { ConcatenateFilesExecutorSchema } from './schema'; + +const ERROR_SOURCE_FILES_EMPTY = 'sourceFiles must contain at least one file'; +const ERROR_NO_FILES_RESOLVED = 'No source files found after resolving patterns'; +const ERROR_SOURCE_NOT_FOUND = (source: string) => `Source file not found: ${source}`; +const NO_FILES_MATCH_PATTERN = (pattern: string) => `No files found matching pattern: ${pattern}`; + +export interface ConcatTransform { + find: string; + replace: string; + flags?: string; +} + +export interface ConcatOptions { + sourceFiles: string[]; + header?: string; + footer?: string; + extractPattern?: string; + extractPatternFlags?: string; + transforms?: ConcatTransform[]; + normalizeLineEndings?: boolean; + separator?: string; +} + +const DEFAULT_SEPARATOR = '\n'; +const DEFAULT_EXTRACT_FLAGS = 'gm'; +const DEFAULT_TRANSFORM_FLAGS = 'g'; + +function compileRegex(pattern: string, flags: string): RegExp { + try { + return new RegExp(pattern, flags); + } catch (error) { + throw new Error( + `Invalid regex pattern '${pattern}' (flags: '${flags}'): ${(error as Error).message}`, + ); + } +} + +function extractContent(content: string, pattern: string, flags: string): string { + const regex = compileRegex(pattern, flags); + const match = regex.exec(content); + return match?.[1] ?? content; +} + +function applyTransforms(content: string, transforms: ConcatTransform[]): string { + return transforms.reduce((result, { find, replace, flags = DEFAULT_TRANSFORM_FLAGS }) => { + return result.replace(compileRegex(find, flags), replace); + }, content); +} + +function applyHeaderFooter(content: string, header?: string, footer?: string): string { + let result = content; + if (header) result = header + result; + if (footer) result = result + footer; + return result; +} + +export async function concatFiles(opts: ConcatOptions): Promise { + const contents = await Promise.all( + opts.sourceFiles.map(async (filePath) => { + const content = await readFileText(filePath); + if (opts.extractPattern) { + return extractContent( + content, + opts.extractPattern, + opts.extractPatternFlags ?? DEFAULT_EXTRACT_FLAGS, + ); + } + return content; + }), + ); + + let output = contents.join(opts.separator ?? DEFAULT_SEPARATOR); + + if (opts.normalizeLineEndings !== false) { + output = normalizeEol(output); + } + + output = applyHeaderFooter(output, opts.header, opts.footer); + + if (opts.transforms?.length) { + output = applyTransforms(output, opts.transforms); + } + + return output; +} + +export async function concatToFile(outputFile: string, opts: ConcatOptions): Promise { + const content = await concatFiles(opts); + await writeFileText(outputFile, content); +} + +async function resolveGlobPattern(pattern: string, projectRoot: string): Promise { + const sourcePath = path.resolve(projectRoot, pattern); + const globPattern = toPosixPath(sourcePath); + const files = await glob(globPattern, { nodir: true }); + + if (files.length === 0) { + logger.verbose(NO_FILES_MATCH_PATTERN(pattern)); + } + + return files.sort(); +} + +async function resolveExactFile(source: string, projectRoot: string): Promise { + const sourcePath = path.resolve(projectRoot, source); + if (!(await exists(sourcePath))) { + throw new Error(ERROR_SOURCE_NOT_FOUND(source)); + } + return sourcePath; +} + +async function resolveSourceFiles(sourceFiles: string[], projectRoot: string): Promise { + const resolved: string[] = []; + + for (const source of sourceFiles) { + if (containsGlobPattern(source)) { + const files = await resolveGlobPattern(source, projectRoot); + resolved.push(...files); + } else { + const file = await resolveExactFile(source, projectRoot); + resolved.push(file); + } + } + + return resolved; +} + +interface ResolvedConcatenate { + projectRoot: string; + resolvedFiles: string[]; + outputPath: string; + options: ConcatenateFilesExecutorSchema; +} + +export default createExecutor({ + name: 'ConcatenateFiles', + resolve: async (options, { projectRoot }) => { + if (!options.sourceFiles?.length) { + throw new Error(ERROR_SOURCE_FILES_EMPTY); + } + + const resolvedFiles = await resolveSourceFiles(options.sourceFiles, projectRoot); + if (resolvedFiles.length === 0) { + throw new Error(ERROR_NO_FILES_RESOLVED); + } + + return { + projectRoot, + resolvedFiles, + outputPath: path.resolve(projectRoot, options.outputFile), + options, + }; + }, + run: async ({ projectRoot, resolvedFiles, outputPath, options }) => { + logger.verbose(`Concatenating ${resolvedFiles.length} files...`); + await concatToFile(outputPath, { + sourceFiles: resolvedFiles, + header: options.header, + footer: options.footer, + extractPattern: options.extractPattern, + extractPatternFlags: options.extractPatternFlags, + transforms: options.transforms, + normalizeLineEndings: options.normalizeLineEndings, + separator: options.separator, + }); + logger.verbose(`Created: ${path.relative(projectRoot, outputPath)}`); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts b/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts index fe34959f49b9..56c4a2691103 100644 --- a/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts +++ b/packages/nx-infra-plugin/src/executors/concatenate-files/executor.ts @@ -1,93 +1,7 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import { glob } from 'glob'; -import { ConcatenateFilesExecutorSchema } from './schema'; -import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; -import { containsGlobPattern } from '../../utils/common'; -import { logError } from '../../utils/error-handler'; -import { exists } from '../../utils/file-operations'; -import { concatToFile } from '../../utils/concat-content'; - -const ERROR_MESSAGES = { - SOURCE_FILES_EMPTY: 'sourceFiles must contain at least one file', - SOURCE_NOT_FOUND: (source: string) => `Source file not found: ${source}`, - NO_FILES_MATCH_PATTERN: (pattern: string) => `No files found matching pattern: ${pattern}`, - NO_FILES_RESOLVED: 'No source files found after resolving patterns', - FAILED_TO_CONCATENATE: 'Failed to concatenate files', -} as const; - -async function resolveGlobPattern(pattern: string, projectRoot: string): Promise { - const sourcePath = path.resolve(projectRoot, pattern); - const globPattern = toPosixPath(sourcePath); - const files = await glob(globPattern, { nodir: true }); - - if (files.length === 0) { - logger.verbose(ERROR_MESSAGES.NO_FILES_MATCH_PATTERN(pattern)); - } - - return files.sort(); -} - -async function resolveExactFile(source: string, projectRoot: string): Promise { - const sourcePath = path.resolve(projectRoot, source); - if (!(await exists(sourcePath))) { - throw new Error(ERROR_MESSAGES.SOURCE_NOT_FOUND(source)); - } - return sourcePath; -} - -async function resolveSourceFiles(sourceFiles: string[], projectRoot: string): Promise { - const resolvedFiles: string[] = []; - - for (const source of sourceFiles) { - if (containsGlobPattern(source)) { - const files = await resolveGlobPattern(source, projectRoot); - resolvedFiles.push(...files); - } else { - const file = await resolveExactFile(source, projectRoot); - resolvedFiles.push(file); - } - } - - return resolvedFiles; -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - - if (!options.sourceFiles?.length) { - logger.error(ERROR_MESSAGES.SOURCE_FILES_EMPTY); - return { success: false }; - } - - try { - const resolvedFiles = await resolveSourceFiles(options.sourceFiles, projectRoot); - - if (resolvedFiles.length === 0) { - logger.error(ERROR_MESSAGES.NO_FILES_RESOLVED); - return { success: false }; - } - - logger.verbose(`Concatenating ${resolvedFiles.length} files...`); - - const outputPath = path.resolve(projectRoot, options.outputFile); - await concatToFile(outputPath, { - sourceFiles: resolvedFiles, - header: options.header, - footer: options.footer, - extractPattern: options.extractPattern, - extractPatternFlags: options.extractPatternFlags, - transforms: options.transforms, - normalizeLineEndings: options.normalizeLineEndings, - separator: options.separator, - }); - logger.verbose(`Created: ${path.relative(projectRoot, outputPath)}`); - - return { success: true }; - } catch (error) { - logError(ERROR_MESSAGES.FAILED_TO_CONCATENATE, error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './concatenate-files.impl'; +export { + concatFiles, + concatToFile, + ConcatOptions, + ConcatTransform, +} from './concatenate-files.impl'; diff --git a/packages/nx-infra-plugin/src/executors/copy-files/copy-files.impl.ts b/packages/nx-infra-plugin/src/executors/copy-files/copy-files.impl.ts new file mode 100644 index 000000000000..3764a8b4d232 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/copy-files/copy-files.impl.ts @@ -0,0 +1,117 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { stat } from 'fs/promises'; +import { logger } from '@nx/devkit'; +import { glob } from 'glob'; +import { createExecutor } from '../../utils/create-executor'; +import { toPosixPath } from '../../utils/path-resolver'; +import { containsGlobPattern } from '../../utils/common'; +import { copyFile, copyRecursive, ensureDir, exists } from '../../utils/file-operations'; +import { CopyFilesExecutorSchema } from './schema'; + +const ERROR_FILES_MUST_BE_ARRAY = 'Files option must be an array'; +const ERROR_NO_FILES_MATCH_PATTERN = (pattern: string) => + `No files found matching pattern: ${pattern}`; +const ERROR_SOURCE_NOT_FOUND = (source: string) => `Source file not found: ${source}`; + +export interface CopyDirectoryOptions { + include?: string[]; + exclude?: string[]; +} + +export async function copyDirectory( + sourceDir: string, + destDir: string, + options: CopyDirectoryOptions = {}, +): Promise { + const includePatterns = options.include ?? ['**/*']; + const excludePatterns = options.exclude ?? []; + const cwd = toPosixPath(sourceDir); + + const relPaths = new Set(); + for (const pattern of includePatterns) { + const matches = await glob(pattern, { + cwd, + nodir: true, + ignore: excludePatterns, + }); + matches.forEach((m) => relPaths.add(m)); + } + + await Promise.all( + [...relPaths].map(async (relPath) => { + const src = path.join(sourceDir, relPath); + const dest = path.join(destDir, relPath); + await ensureDir(path.dirname(dest)); + await fs.copyFile(src, dest); + }), + ); +} + +async function copyGlobPatternFiles( + sourcePath: string, + destPath: string, + excludePatterns: string[] = [], +): Promise { + const globPattern = toPosixPath(sourcePath); + const ignore = excludePatterns.map(toPosixPath); + const files = await glob(globPattern, { nodir: true, ignore }); + + if (files.length === 0) { + throw new Error(ERROR_NO_FILES_MATCH_PATTERN(sourcePath)); + } + + await ensureDir(destPath); + + for (const file of files) { + const fileName = path.basename(file); + const destFile = path.join(destPath, fileName); + await copyFile(file, destFile); + logger.verbose(`Copied file ${file} -> ${destFile}`); + } +} + +async function copyDirectPath(sourcePath: string, destPath: string): Promise { + if (!(await exists(sourcePath))) { + throw new Error(ERROR_SOURCE_NOT_FOUND(sourcePath)); + } + + const sourceStat = await stat(sourcePath); + + if (sourceStat.isDirectory()) { + await copyRecursive(sourcePath, destPath); + logger.verbose(`Copied directory ${sourcePath} -> ${destPath}`); + return; + } + + await copyFile(sourcePath, destPath); + logger.verbose(`Copied file ${sourcePath} -> ${destPath}`); +} + +interface ResolvedCopyFiles { + projectRoot: string; +} + +export default createExecutor({ + name: 'CopyFiles', + resolve: (options, { projectRoot }) => { + if (!options.files || !Array.isArray(options.files)) { + throw new Error(ERROR_FILES_MUST_BE_ARRAY); + } + return { projectRoot }; + }, + run: async ({ projectRoot }, options) => { + for (const { from, to, excludePatterns } of options.files) { + const sourcePath = path.resolve(projectRoot, from); + const destPath = path.resolve(projectRoot, to); + + if (containsGlobPattern(from)) { + await copyGlobPatternFiles(sourcePath, destPath, excludePatterns); + } else { + await copyDirectPath(sourcePath, destPath); + } + } + }, +}); + +export { ResolvedCopyFiles }; diff --git a/packages/nx-infra-plugin/src/executors/copy-files/executor.ts b/packages/nx-infra-plugin/src/executors/copy-files/executor.ts index fffe6b28480f..63141de820b2 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/executor.ts +++ b/packages/nx-infra-plugin/src/executors/copy-files/executor.ts @@ -1,92 +1,2 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import { stat } from 'fs/promises'; -import { glob } from 'glob'; -import { CopyFilesExecutorSchema } from './schema'; -import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; -import { containsGlobPattern } from '../../utils/common'; -import { logError } from '../../utils/error-handler'; -import { copyFile, copyRecursive, exists, ensureDir } from '../../utils/file-operations'; - -const ERROR_MESSAGES = { - FILES_MUST_BE_ARRAY: 'Files option must be an array', - FAILED_TO_COPY: 'Failed to copy files', - NO_FILES_MATCH_PATTERN: (pattern: string) => `No files found matching pattern: ${pattern}`, - SOURCE_NOT_FOUND: (source: string) => `Source file not found: ${source}`, -} as const; - -async function copyGlobPatternFiles( - sourcePath: string, - destPath: string, - excludePatterns: string[] = [], -): Promise<{ success: boolean }> { - const globPattern = toPosixPath(sourcePath); - const ignore = excludePatterns.map(toPosixPath); - const files = await glob(globPattern, { nodir: true, ignore }); - - if (files.length === 0) { - logger.error(ERROR_MESSAGES.NO_FILES_MATCH_PATTERN(sourcePath)); - return { success: false }; - } - - await ensureDir(destPath); - - for (const file of files) { - const fileName = path.basename(file); - const destFile = path.join(destPath, fileName); - await copyFile(file, destFile); - logger.verbose(`Copied file ${file} -> ${destFile}`); - } - - return { success: true }; -} - -async function copyDirectPath(sourcePath: string, destPath: string): Promise<{ success: boolean }> { - if (!(await exists(sourcePath))) { - logger.error(ERROR_MESSAGES.SOURCE_NOT_FOUND(sourcePath)); - return { success: false }; - } - - const sourceStat = await stat(sourcePath); - - if (sourceStat.isDirectory()) { - await copyRecursive(sourcePath, destPath); - logger.verbose(`Copied directory ${sourcePath} -> ${destPath}`); - return { success: true }; - } - - await copyFile(sourcePath, destPath); - logger.verbose(`Copied file ${sourcePath} -> ${destPath}`); - return { success: true }; -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - - if (!options.files || !Array.isArray(options.files)) { - logger.error(ERROR_MESSAGES.FILES_MUST_BE_ARRAY); - return { success: false }; - } - - try { - for (const { from, to, excludePatterns } of options.files) { - const sourcePath = path.resolve(projectRoot, from); - const destPath = path.resolve(projectRoot, to); - - const result = containsGlobPattern(from) - ? await copyGlobPatternFiles(sourcePath, destPath, excludePatterns) - : await copyDirectPath(sourcePath, destPath); - - if (!result.success) { - return { success: false }; - } - } - - return { success: true }; - } catch (error) { - logError(ERROR_MESSAGES.FAILED_TO_COPY, error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './copy-files.impl'; +export { copyDirectory, CopyDirectoryOptions } from './copy-files.impl'; diff --git a/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/create-dual-mode-manifest.impl.ts b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/create-dual-mode-manifest.impl.ts new file mode 100644 index 000000000000..e95b665b4ffe --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/create-dual-mode-manifest/create-dual-mode-manifest.impl.ts @@ -0,0 +1,220 @@ +import { logger } from '@nx/devkit'; +import * as path from 'path'; +import { glob } from 'glob'; +import { minimatch } from 'minimatch'; +import { createExecutor } from '../../utils/create-executor'; +import { CreateDualModeManifestExecutorSchema } from './schema'; +import { SideEffectFinder } from './side-effect-finder'; +import { toPosixPath } from '../../utils/path-resolver'; +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}`, +} as const; + +function normalizePackagePath(value: string): string { + return value.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 (error) { + logger.verbose(`Side effect analysis failed for ${esmFilePath}: ${error}`); + } + + 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 = toPosixPath(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; +} + +interface ResolvedCreateDualModeManifest { + esmDir: string; + cjsDir: string; + outputDir: string; + srcDir: string; + excludePatterns: string[]; + generatedDtsFiles: string[]; +} + +export default createExecutor( + { + name: 'CreateDualModeManifest', + resolve: (options, { projectRoot }) => { + return { + esmDir: path.resolve(projectRoot, options.esmDir), + cjsDir: path.resolve(projectRoot, options.cjsDir), + outputDir: path.resolve(projectRoot, options.outputDir), + srcDir: path.resolve(projectRoot, options.srcDir), + excludePatterns: options.excludePatterns || [], + generatedDtsFiles: options.generatedDtsFiles || [], + }; + }, + run: async (resolved) => { + logger.verbose(`Creating dual-mode manifest files...`); + logger.verbose(` ESM dir: ${resolved.esmDir}`); + logger.verbose(` CJS dir: ${resolved.cjsDir}`); + logger.verbose(` Output dir: ${resolved.outputDir}`); + logger.verbose(` Source dir: ${resolved.srcDir}`); + logger.verbose(` Exclude patterns: ${resolved.excludePatterns.join(', ') || '(none)'}`); + + await validateDirectories(resolved.esmDir, resolved.cjsDir); + + const files = await discoverJsFiles(resolved.esmDir); + + if (files.length === 0) { + logger.warn(`No JS files found in ESM directory: ${resolved.esmDir}`); + return; + } + + 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, + resolved.esmDir, + resolved.srcDir, + resolved.outputDir, + resolved.excludePatterns, + resolved.generatedDtsFiles, + sideEffectFinder, + ); + if (created) { + createdCount++; + } + } + + logger.info(`Created ${createdCount} package.json manifest files`); + }, + }, +); 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 index 4a363ef66690..11edcbe1b41d 100644 --- 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 @@ -1,218 +1 @@ -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, toPosixPath } from '../../utils/path-resolver'; -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 = toPosixPath(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; +export { default } from './create-dual-mode-manifest.impl'; diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/dts-bundle.impl.ts b/packages/nx-infra-plugin/src/executors/dts-bundle/dts-bundle.impl.ts new file mode 100644 index 000000000000..63cf4ec11b78 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/dts-bundle.impl.ts @@ -0,0 +1,83 @@ +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { loadProjectPackageJson, writeFileText } from '../../utils/file-operations'; +import { concatFiles } from '../concatenate-files/concatenate-files.impl'; +import { renderLicenseBannerForName } from '../add-license-headers/add-license-headers.impl'; +import { DEFAULT_EULA_URL, resolveLicenseTemplate } from '../add-license-headers/defaults'; +import type { PackageJson } from '../../utils/types'; +import { DtsBundleExecutorSchema } from './schema'; + +const STRIP_DECLARE_GLOBAL = /^declare global\s*\{([\s\S]*?)^\}/gm; +const STRIP_JQUERY_INTERFACE_BODY = /(interface JQuery\b[\s\S]*?\{)[\s\S]+?(\})/gm; +const PACKAGE_FOOTER = '\nexport default DevExpress;'; + +interface ResolvedDtsBundle { + pkg: PackageJson; + templatePath: string; + eulaUrl: string; + resolvedSources: string[]; + artifactPath: string; + packagePath: string; + artifactRelative: string; + packageRelative: string; +} + +export default createExecutor({ + name: 'DtsBundle', + resolve: async (options, { projectRoot }) => { + const pkg = await loadProjectPackageJson(projectRoot); + const templatePath = resolveLicenseTemplate(projectRoot, options); + const resolvedSources = options.bundleSources.map((source) => + path.resolve(projectRoot, source), + ); + const artifactPath = path.resolve(projectRoot, options.artifactPath); + const packagePath = path.resolve(projectRoot, options.packagePath); + + return { + pkg, + templatePath, + eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, + resolvedSources, + artifactPath, + packagePath, + artifactRelative: options.artifactPath, + packageRelative: options.packagePath, + }; + }, + run: async (resolved) => { + const concatContent = await concatFiles({ + sourceFiles: resolved.resolvedSources, + normalizeLineEndings: false, + }); + + const bannerInputs = { + pkg: resolved.pkg, + templatePath: resolved.templatePath, + eulaUrl: resolved.eulaUrl, + }; + + const [artifactBanner, packageBanner] = await Promise.all([ + renderLicenseBannerForName( + { ...bannerInputs, commentType: '!' }, + path.basename(resolved.artifactPath), + ), + renderLicenseBannerForName( + { ...bannerInputs, commentType: '*' }, + path.basename(resolved.packagePath), + ), + ]); + + const artifactContent = artifactBanner + concatContent.replace(STRIP_DECLARE_GLOBAL, '$1'); + const packageContent = + packageBanner + concatContent.replace(STRIP_JQUERY_INTERFACE_BODY, '$1$2') + PACKAGE_FOOTER; + + await Promise.all([ + writeFileText(resolved.artifactPath, artifactContent), + writeFileText(resolved.packagePath, packageContent), + ]); + + logger.verbose(`Written artifact bundle: ${resolved.artifactRelative}`); + logger.verbose(`Written package bundle: ${resolved.packageRelative}`); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.e2e.spec.ts index db1c6eac03b9..68cbdc348c82 100644 --- a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.e2e.spec.ts @@ -71,7 +71,7 @@ describe('DtsBundleExecutor E2E', () => { cleanupTempDir(tempDir); }); - it('should produce artifact bundle with bang-license and stripped declare global wrapper', async () => { + it('should produce artifact bundle with bang-comment banner and stripped declare global wrapper', async () => { const result = await executor(OPTIONS, context); expect(result.success).toBe(true); @@ -80,17 +80,12 @@ describe('DtsBundleExecutor E2E', () => { ); expect(artifactContent).toMatch(/^\/\*!/); - expect(artifactContent).toContain('DevExtreme (dx.all.d.ts)'); - expect(artifactContent).toContain('https://js.devexpress.com/Licensing/'); - expect(artifactContent).not.toContain('declare global'); expect(artifactContent).toContain('interface JQuery'); - - expect(artifactContent).toContain('DevExpress'); expect(artifactContent).toContain('EventObject'); }); - it('should produce package bundle with star-license, footer, and stripped jQuery interface body', async () => { + it('should produce package bundle with star-comment banner, footer, and stripped jQuery interface body', async () => { const result = await executor(OPTIONS, context); expect(result.success).toBe(true); @@ -99,14 +94,8 @@ describe('DtsBundleExecutor E2E', () => { ); expect(packageContent).toMatch(/^\/\*\*/); - expect(packageContent).not.toMatch(/^\/\*!/); - expect(packageContent).toContain('DevExtreme (dx.all.d.ts)'); - expect(packageContent).toContain('\nexport default DevExpress;'); - expect(packageContent).toContain('interface JQuery'); expect(packageContent).not.toContain('dxButton()'); - - expect(packageContent).toContain('EventObject'); }); }); diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts index d2f6f97a4ba5..cb650aabca55 100644 --- a/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts @@ -1,85 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import { DtsBundleExecutorSchema } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; -import { readJson, writeFileText } from '../../utils/file-operations'; -import { concatFiles } from '../../utils/concat-content'; -import { buildLicenseBannerRenderer } from '../../utils/license-banner'; -import { DEFAULT_LICENSE_TEMPLATE_EULA, DEFAULT_EULA_URL } from '../add-license-headers/defaults'; -import type { PackageJson } from '../../utils/types'; - -const STRIP_DECLARE_GLOBAL = /^declare global\s*\{([\s\S]*?)^\}/gm; -const STRIP_JQUERY_INTERFACE_BODY = /(interface JQuery\b[\s\S]*?\{)[\s\S]+?(\})/gm; - -async function writeArtifactBundle( - artifactPath: string, - concatContent: string, - banner: string, -): Promise { - const content = concatContent.replace(STRIP_DECLARE_GLOBAL, '$1'); - await writeFileText(artifactPath, banner + content); -} - -async function writePackageBundle( - packagePath: string, - concatContent: string, - banner: string, -): Promise { - const content = concatContent.replace(STRIP_JQUERY_INTERFACE_BODY, '$1$2'); - await writeFileText(packagePath, banner + content + '\nexport default DevExpress;'); -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - const licenseTemplatePath = options.licenseTemplateFile - ? path.resolve(projectRoot, options.licenseTemplateFile) - : DEFAULT_LICENSE_TEMPLATE_EULA; - - let pkg: PackageJson; - try { - pkg = await readJson(path.join(projectRoot, 'package.json')); - } catch (error) { - logError('Failed to read package.json', error); - return { success: false }; - } - - try { - const resolvedSources = options.bundleSources.map((s) => path.resolve(projectRoot, s)); - - const concatContent = await concatFiles({ - sourceFiles: resolvedSources, - normalizeLineEndings: false, - }); - - const bannerBase = { - templatePath: licenseTemplatePath, - pkg, - eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, - }; - - const [renderArtifactBanner, renderPackageBanner] = await Promise.all([ - buildLicenseBannerRenderer({ ...bannerBase, commentType: '!' }), - buildLicenseBannerRenderer({ ...bannerBase, commentType: '*' }), - ]); - - const artifactPath = path.resolve(projectRoot, options.artifactPath); - const packagePath = path.resolve(projectRoot, options.packagePath); - const artifactBanner = renderArtifactBanner(path.basename(artifactPath)); - const packageBanner = renderPackageBanner(path.basename(packagePath)); - - await Promise.all([ - writeArtifactBundle(artifactPath, concatContent, artifactBanner), - writePackageBundle(packagePath, concatContent, packageBanner), - ]); - logger.verbose(`Written artifact bundle: ${options.artifactPath}`); - logger.verbose(`Written package bundle: ${options.packagePath}`); - - return { success: true }; - } catch (error) { - logError('DtsBundle executor failed', error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './dts-bundle.impl'; diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/dts-modules.impl.ts b/packages/nx-infra-plugin/src/executors/dts-modules/dts-modules.impl.ts new file mode 100644 index 000000000000..0773815325e3 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-modules/dts-modules.impl.ts @@ -0,0 +1,111 @@ +import * as path from 'path'; +import { glob } from 'glob'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { toPosixPath } from '../../utils/path-resolver'; +import { loadProjectPackageJson, readFileText, writeFileText } from '../../utils/file-operations'; +import { copyDirectory } from '../copy-files/copy-files.impl'; +import { applyLicenseHeadersToFiles } from '../add-license-headers/add-license-headers.impl'; +import { stripDebug } from '../compress/compress.impl'; +import { DEFAULT_EULA_URL, resolveLicenseTemplate } from '../add-license-headers/defaults'; +import type { PackageJson } from '../../utils/types'; +import { DtsModulesExecutorSchema } from './schema'; + +const BUNDLES_PREFIX = 'bundles/'; +const BACKSLASH_REGEX = /\\/g; +const FORWARD_SLASH = '/'; + +interface ResolvedDtsModules { + pkg: PackageJson; + templatePath: string; + eulaUrl: string; + sourceDir: string; + outputDir: string; + templatesDir: string; + sourceDirRelative: string; + outputDirRelative: string; + templatesDirRelative: string; +} + +function toRelativePosix(baseDir: string, filePath: string): string { + return path.relative(baseDir, filePath).replace(BACKSLASH_REGEX, FORWARD_SLASH); +} + +export default createExecutor({ + name: 'DtsModules', + resolve: async (options, { projectRoot }) => { + const pkg = await loadProjectPackageJson(projectRoot); + const templatePath = resolveLicenseTemplate(projectRoot, options); + + return { + pkg, + templatePath, + eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, + sourceDir: path.resolve(projectRoot, options.sourceDir), + outputDir: path.resolve(projectRoot, options.outputDir), + templatesDir: path.resolve(projectRoot, options.templatesDir), + sourceDirRelative: options.sourceDir, + outputDirRelative: options.outputDir, + templatesDirRelative: options.templatesDir, + }; + }, + run: async (resolved) => { + await copyDirectory(resolved.templatesDir, resolved.outputDir); + logger.verbose(`Copied templates from ${resolved.templatesDirRelative}`); + + await copyDirectory(resolved.sourceDir, resolved.outputDir, { include: ['**/*.d.ts'] }); + logger.verbose( + `Copied .d.ts files from ${resolved.sourceDirRelative} to ${resolved.outputDirRelative}`, + ); + + const outputCwd = toPosixPath(resolved.outputDir); + const dtsFiles = await glob('**/*.d.ts', { cwd: outputCwd, nodir: true, absolute: true }); + + const templatesCwd = toPosixPath(resolved.templatesDir); + const templateJsRelativePaths = await glob('**/*.js', { + cwd: templatesCwd, + nodir: true, + }); + const jsFiles = templateJsRelativePaths.map((relative) => + path.resolve(resolved.outputDir, relative), + ); + + const bannerInputs = { + pkg: resolved.pkg, + templatePath: resolved.templatePath, + eulaUrl: resolved.eulaUrl, + commentType: '*' as const, + }; + + await Promise.all([ + applyLicenseHeadersToFiles({ + ...bannerInputs, + files: dtsFiles, + baseDir: resolved.outputDir, + filenameMode: 'relative', + }), + applyLicenseHeadersToFiles({ + ...bannerInputs, + files: jsFiles, + baseDir: resolved.outputDir, + filenameMode: 'basename', + }), + ]); + logger.verbose('Applied star-license banners'); + + const dtsNonBundles = dtsFiles.filter( + (filePath) => !toRelativePosix(resolved.outputDir, filePath).startsWith(BUNDLES_PREFIX), + ); + + await Promise.all( + dtsNonBundles.map(async (filePath) => { + const content = await readFileText(filePath); + const stripped = stripDebug(content); + if (stripped !== content) { + await writeFileText(filePath, stripped); + } + }), + ); + logger.verbose('Stripped debug blocks from .d.ts files'); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts index cfea6733b7fb..4a1d0108115c 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts @@ -84,7 +84,7 @@ describe('DtsModulesExecutor E2E', () => { cleanupTempDir(tempDir); }); - it('should produce the expected file tree (real .d.ts + templates) with star-license banners and stripped debug blocks', async () => { + it('should produce the expected file tree with banners applied and debug blocks stripped', async () => { const result = await executor(OPTIONS, context); expect(result.success).toBe(true); @@ -92,25 +92,12 @@ describe('DtsModulesExecutor E2E', () => { const accordionContent = await readFileText(path.join(outDir, 'accordion.d.ts')); expect(accordionContent).toMatch(/^\/\*\*/); - expect(accordionContent).toContain('DevExtreme (accordion.d.ts)'); - expect(accordionContent).not.toContain('#DEBUG'); - expect(accordionContent).not.toContain('debugHelper'); expect(accordionContent).toContain('accordion'); - const buttonContent = await readFileText(path.join(outDir, 'ui', 'button.d.ts')); - expect(buttonContent).toMatch(/^\/\*\*/); - expect(buttonContent).toContain('DevExtreme (ui/button.d.ts)'); - const hoverContent = await readFileText(path.join(outDir, 'events', 'hover.d.ts')); - expect(hoverContent).toMatch(/^\/\*\*/); - expect(hoverContent).toContain('DevExtreme (events/hover.d.ts)'); expect(hoverContent).toContain(HOVER_TEMPLATE); - const jqContent = await readFileText(path.join(outDir, 'integration', 'jquery.d.ts')); - expect(jqContent).toMatch(/^\/\*\*/); - const dxAllJsContent = await readFileText(path.join(outDir, 'bundles', 'dx.all.js')); - expect(dxAllJsContent).toMatch(/^\/\*\*/); expect(dxAllJsContent).toContain('DevExtreme (dx.all.js)'); expect(dxAllJsContent).not.toContain('DevExtreme (bundles/dx.all.js)'); expect(dxAllJsContent).toContain(DX_ALL_JS_TEMPLATE); @@ -157,7 +144,6 @@ describe('DtsModulesExecutor E2E', () => { sourceDir: './js', outputDir: './artifacts/npm/devextreme', templatesDir: './build/npm-templates', - eulaUrl: 'https://js.devexpress.com/Licensing/', }; const result = await executor(options, context); @@ -166,6 +152,5 @@ describe('DtsModulesExecutor E2E', () => { const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); const accordionContent = await readFileText(path.join(outDir, 'accordion.d.ts')); expect(accordionContent).toMatch(/^\/\*\*/); - expect(accordionContent).toContain('DevExtreme (accordion.d.ts)'); }); }); diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts index ec358078c3e1..9ca804ec2bac 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts @@ -1,92 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import { glob } from 'glob'; -import { DtsModulesExecutorSchema } from './schema'; -import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; -import { readJson, readFileText, writeFileText } from '../../utils/file-operations'; -import { copyDirectory } from '../../utils/copy-directory'; -import { buildLicenseBannerRenderer } from '../../utils/license-banner'; -import { stripDebug } from '../../utils/debug-strip'; -import { DEFAULT_LICENSE_TEMPLATE_EULA, DEFAULT_EULA_URL } from '../add-license-headers/defaults'; -import type { PackageJson } from '../../utils/types'; - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - const sourceDir = path.resolve(projectRoot, options.sourceDir); - const outputDir = path.resolve(projectRoot, options.outputDir); - const templatesDir = path.resolve(projectRoot, options.templatesDir); - const licenseTemplatePath = options.licenseTemplateFile - ? path.resolve(projectRoot, options.licenseTemplateFile) - : DEFAULT_LICENSE_TEMPLATE_EULA; - - try { - await copyDirectory(templatesDir, outputDir); - logger.verbose(`Copied templates from ${options.templatesDir}`); - - await copyDirectory(sourceDir, outputDir, { include: ['**/*.d.ts'] }); - logger.verbose(`Copied .d.ts files from ${options.sourceDir} to ${options.outputDir}`); - - let pkg: PackageJson; - try { - pkg = await readJson(path.join(projectRoot, 'package.json')); - } catch (pkgError) { - logError('Failed to read package.json', pkgError); - return { success: false }; - } - - const bannerBase = { - templatePath: licenseTemplatePath, - pkg, - eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, - }; - - const cwd = toPosixPath(outputDir); - const dtsFiles = await glob('**/*.d.ts', { cwd, nodir: true, absolute: true }); - - const templatesCwd = toPosixPath(templatesDir); - const templateJsRelPaths = await glob('**/*.js', { cwd: templatesCwd, nodir: true }); - const jsFiles = templateJsRelPaths.map((rel) => path.resolve(outputDir, rel)); - - const renderBanner = await buildLicenseBannerRenderer({ ...bannerBase, commentType: '*' }); - - await Promise.all([ - ...dtsFiles.map(async (filePath) => { - const fileRelative = path.relative(outputDir, filePath).replace(/\\/g, '/'); - const banner = renderBanner(fileRelative); - const content = await readFileText(filePath); - await writeFileText(filePath, banner + content); - }), - ...jsFiles.map(async (filePath) => { - const fileRelative = path.basename(filePath); - const banner = renderBanner(fileRelative); - const content = await readFileText(filePath); - await writeFileText(filePath, banner + content); - }), - ]); - logger.verbose('Applied star-license banners'); - - const dtsNonBundles = dtsFiles.filter((f) => { - const rel = path.relative(outputDir, f).replace(/\\/g, '/'); - return !rel.startsWith('bundles/'); - }); - - await Promise.all( - dtsNonBundles.map(async (filePath) => { - const content = await readFileText(filePath); - const stripped = stripDebug(content); - if (stripped !== content) { - await writeFileText(filePath, stripped); - } - }), - ); - logger.verbose('Stripped debug blocks from .d.ts files'); - - return { success: true }; - } catch (error) { - logError('DtsModules executor failed', error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './dts-modules.impl'; diff --git a/packages/nx-infra-plugin/src/executors/generate-component-names/executor.ts b/packages/nx-infra-plugin/src/executors/generate-component-names/executor.ts index 91ad72e971d0..b8df37e009c7 100644 --- a/packages/nx-infra-plugin/src/executors/generate-component-names/executor.ts +++ b/packages/nx-infra-plugin/src/executors/generate-component-names/executor.ts @@ -1,70 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import { GenerateComponentNamesExecutorSchema } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; -import { ensureDir } from '../../utils/file-operations'; - -const DEFAULT_COMPONENT_FILES_PATH = './src/ui/'; -const DEFAULT_OUTPUT_FILE_NAME = './tests/src/server/component-names.ts'; -const DEFAULT_EXCLUDED_FILE_NAMES = []; - -const MSG_GENERATING = 'Generating component-names.ts...'; -const MSG_GENERATED = '✓ Generated component-names.ts'; -const ERROR_GENERATION_FAILED = 'Component names generation failed'; - -function validateDependencies(): void { - try { - require.resolve('devextreme-internal-tools'); - } catch (error) { - throw new Error( - 'devextreme-internal-tools package not found. Please ensure it is installed as a dependency.', - ); - } -} - -function buildGeneratorConfig( - options: GenerateComponentNamesExecutorSchema, - projectRoot: string, -): Record { - const componentFilesPath = options.componentFilesPath || DEFAULT_COMPONENT_FILES_PATH; - const outputFileName = options.outputFileName || DEFAULT_OUTPUT_FILE_NAME; - const excludedFileNames = options.excludedFileNames || DEFAULT_EXCLUDED_FILE_NAMES; - - return { - componentFilesPath: path.resolve(projectRoot, componentFilesPath), - excludedFileNames, - outputFileName: path.resolve(projectRoot, outputFileName), - }; -} - -const runExecutor: PromiseExecutor = async ( - options, - context, -) => { - const projectRoot = resolveProjectPath(context); - - try { - logger.verbose(MSG_GENERATING); - - validateDependencies(); - - const config = buildGeneratorConfig(options, projectRoot); - - const outputDir = path.dirname(config.outputFileName as string); - await ensureDir(outputDir); - - const { AngularComponentNamesGenerator } = require('devextreme-internal-tools'); - - const generator = new AngularComponentNamesGenerator(config); - generator.generate(); - - logger.verbose(MSG_GENERATED); - return { success: true }; - } catch (error) { - logError(ERROR_GENERATION_FAILED, error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './generate-component-names.impl'; diff --git a/packages/nx-infra-plugin/src/executors/generate-component-names/generate-component-names.impl.ts b/packages/nx-infra-plugin/src/executors/generate-component-names/generate-component-names.impl.ts new file mode 100644 index 000000000000..b1a110973387 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/generate-component-names/generate-component-names.impl.ts @@ -0,0 +1,72 @@ +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { ensureDir } from '../../utils/file-operations'; +import { GenerateComponentNamesExecutorSchema } from './schema'; + +const DEFAULT_COMPONENT_FILES_PATH = './src/ui/'; +const DEFAULT_OUTPUT_FILE_NAME = './tests/src/server/component-names.ts'; +const DEFAULT_EXCLUDED_FILE_NAMES: string[] = []; + +const MSG_GENERATING = 'Generating component-names.ts...'; +const MSG_GENERATED = '✓ Generated component-names.ts'; + +interface GeneratorConfig { + componentFilesPath: string; + excludedFileNames: string[]; + outputFileName: string; +} + +function validateDependencies(): void { + try { + require.resolve('devextreme-internal-tools'); + } catch (error) { + throw new Error( + 'devextreme-internal-tools package not found. Please ensure it is installed as a dependency.', + ); + } +} + +function buildGeneratorConfig( + options: GenerateComponentNamesExecutorSchema, + projectRoot: string, +): GeneratorConfig { + const componentFilesPath = options.componentFilesPath || DEFAULT_COMPONENT_FILES_PATH; + const outputFileName = options.outputFileName || DEFAULT_OUTPUT_FILE_NAME; + const excludedFileNames = options.excludedFileNames || DEFAULT_EXCLUDED_FILE_NAMES; + + return { + componentFilesPath: path.resolve(projectRoot, componentFilesPath), + excludedFileNames, + outputFileName: path.resolve(projectRoot, outputFileName), + }; +} + +interface ResolvedGenerateComponentNames { + config: GeneratorConfig; +} + +export default createExecutor( + { + name: 'GenerateComponentNames', + resolve: (options, { projectRoot }) => { + const config = buildGeneratorConfig(options, projectRoot); + return { config }; + }, + run: async ({ config }) => { + logger.verbose(MSG_GENERATING); + + validateDependencies(); + + const outputDir = path.dirname(config.outputFileName); + await ensureDir(outputDir); + + const { AngularComponentNamesGenerator } = require('devextreme-internal-tools'); + + const generator = new AngularComponentNamesGenerator(config); + generator.generate(); + + logger.verbose(MSG_GENERATED); + }, + }, +); diff --git a/packages/nx-infra-plugin/src/executors/generate-components/executor.ts b/packages/nx-infra-plugin/src/executors/generate-components/executor.ts index 2cc927b1fae9..8ed677fefdac 100644 --- a/packages/nx-infra-plugin/src/executors/generate-components/executor.ts +++ b/packages/nx-infra-plugin/src/executors/generate-components/executor.ts @@ -1,326 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as fs from 'fs'; -import * as path from 'path'; -import { GenerateReactComponentsExecutorSchema, Framework } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; -import { logError, getErrorMessage } from '../../utils/error-handler'; -import { getFrameworkHandler, GenerationConfig } from './framework-handlers'; - -const DEFAULT_COMPONENTS_DIR = './src'; -const DEFAULT_INDEX_FILE_NAME = './src/index.ts'; -const CORE_DIR = 'core'; - -const TOOLS_DIR = 'tools'; -const GENERATORS_CONFIG_FILE = 'generators-config.js'; -const METADATA_PACKAGE = 'devextreme-metadata'; -const METADATA_FILE = 'integration-data.json'; -const INTERNAL_TOOLS_PACKAGE = 'devextreme-internal-tools'; - -const DEFAULT_BASE_COMPONENT = './core/component'; -const DEFAULT_EXTENSION_COMPONENT = './core/extension-component'; -const DEFAULT_CONFIG_COMPONENT = './core/nested-option'; - -const WIDGETS_PACKAGE = 'devextreme'; - -const MSG_LOADING_METADATA = '📋 Loading metadata'; -const MSG_GENERATORS_CONFIG_NOT_FOUND = - '⚠️ generators-config.js not found, proceeding without unifiedConfig'; -const MSG_GENERATION_COMPLETED = '✓ Component generation completed'; - -function createMessages(framework: Framework) { - const frameworkName = framework.charAt(0).toUpperCase() + framework.slice(1); - return { - loadedConfig: `✓ Loaded ${frameworkName} configuration from generators-config.js`, - generating: `⚙️ Generating ${frameworkName} components`, - generationSuccess: `✨ ${frameworkName} component generation successful!`, - starting: `🔧 Starting ${frameworkName} component generation`, - generationFailed: `❌ ${frameworkName} component generation failed`, - }; -} - -const ERROR_METADATA_NOT_FOUND = - 'Could not find devextreme-metadata/integration-data.json. Please ensure devextreme-metadata is installed or provide a metadataPath option.'; - -const PARENT_DIR_PREFIX = '../'; -const DOT_SLASH_PREFIX = './'; - -const ENCODING_UTF8 = 'utf-8'; - -const EXPORT_PATTERN = /export \{/g; - -function resolveMetadataPath( - options: GenerateReactComponentsExecutorSchema, - absoluteProjectRoot: string, - workspaceRoot: string, -): string { - if (options.metadataPath) { - return resolveCustomMetadataPath(options.metadataPath, absoluteProjectRoot, workspaceRoot); - } - - return resolveDefaultMetadataPath(); -} - -function resolveCustomMetadataPath( - metadataPath: string, - absoluteProjectRoot: string, - workspaceRoot: string, -): string { - const relativeToProject = path.resolve(absoluteProjectRoot, metadataPath); - if (fs.existsSync(relativeToProject)) { - return relativeToProject; - } - - const relativeToWorkspace = path.resolve(workspaceRoot, metadataPath); - if (fs.existsSync(relativeToWorkspace)) { - return relativeToWorkspace; - } - - if (metadataPath.startsWith(PARENT_DIR_PREFIX)) { - return path.resolve(workspaceRoot, metadataPath); - } - - return relativeToProject; -} - -function resolveDefaultMetadataPath(): string { - try { - return require.resolve(`${METADATA_PACKAGE}/${METADATA_FILE}`); - } catch (error) { - throw new Error(ERROR_METADATA_NOT_FOUND); - } -} - -function loadMetadata(metadataPath: string): any { - logger.verbose(MSG_LOADING_METADATA); - logger.verbose(` Path: ${metadataPath}`); - - if (!fs.existsSync(metadataPath)) { - throw new Error(`Metadata file not found: ${metadataPath}`); - } - - const metadataContent = fs.readFileSync(metadataPath, ENCODING_UTF8); - const metaData = JSON.parse(metadataContent); - - const widgetCount = Object.keys(metaData.Widgets || {}).length; - logger.verbose(`✓ Loaded ${widgetCount} widget definitions`); - - return metaData; -} - -function loadFrameworkConfig( - workspaceRoot: string, - projectRoot: string, - configName: string, - framework: Framework, -): any { - const isFilePath = - configName.startsWith(DOT_SLASH_PREFIX) || configName.startsWith(PARENT_DIR_PREFIX); - - if (isFilePath) { - return loadConfigFromFile(projectRoot, configName, framework); - } - - return loadConfigFromGeneratorsFile(workspaceRoot, configName, framework); -} - -function loadConfigFromFile(projectRoot: string, configPath: string, framework: Framework): any { - const absoluteConfigPath = path.resolve(projectRoot, configPath); - - if (!fs.existsSync(absoluteConfigPath)) { - logger.warn(`⚠️ Configuration file not found: ${configPath}`); - return undefined; - } - - try { - delete require.cache[require.resolve(absoluteConfigPath)]; - const config = require(absoluteConfigPath); - - const frameworkName = framework.charAt(0).toUpperCase() + framework.slice(1); - logger.verbose(`✓ Loaded ${frameworkName} configuration from ${configPath}`); - return config; - } catch (error) { - logger.warn(`⚠️ Could not load configuration from ${configPath}: ${getErrorMessage(error)}`); - return undefined; - } -} - -function loadConfigFromGeneratorsFile( - workspaceRoot: string, - configName: string, - framework: Framework, -): any { - const generatorsConfigPath = path.join(workspaceRoot, TOOLS_DIR, GENERATORS_CONFIG_FILE); - - if (!fs.existsSync(generatorsConfigPath)) { - logger.warn(MSG_GENERATORS_CONFIG_NOT_FOUND); - return undefined; - } - - try { - const generatorsConfig = require(generatorsConfigPath); - const config = generatorsConfig[configName]; - - if (!config) { - logger.warn(`⚠️ Configuration '${configName}' not found in generators-config.js`); - return undefined; - } - - const messages = createMessages(framework); - logger.verbose(messages.loadedConfig); - return config; - } catch (error) { - logger.warn(`⚠️ Could not load generators-config.js: ${getErrorMessage(error)}`); - return undefined; - } -} - -function loadGenerationFunction(framework: Framework): any { - if (framework === 'angular') { - return null; - } - - const handler = getFrameworkHandler(framework); - const functionName = handler.getDefaults().generationFunctionName; - - try { - const internalTools = require(INTERNAL_TOOLS_PACKAGE); - const generationFunction = internalTools[functionName]; - - if (!generationFunction) { - throw new Error( - `Generation function '${functionName}' not found in ${INTERNAL_TOOLS_PACKAGE}`, - ); - } - - return generationFunction; - } catch (error) { - throw new Error( - `Could not load ${functionName} from devextreme-internal-tools. Please ensure devextreme-internal-tools is installed as a dependency. Error: ${getErrorMessage( - error, - )}`, - ); - } -} - -function buildGenerationConfig( - options: GenerateReactComponentsExecutorSchema, - componentsDir: string, - indexFileName: string, - frameworkConfig: any, -): GenerationConfig { - return { - metaData: undefined, - components: { - baseComponent: options.baseComponent || DEFAULT_BASE_COMPONENT, - extensionComponent: options.extensionComponent || DEFAULT_EXTENSION_COMPONENT, - configComponent: options.configComponent || DEFAULT_CONFIG_COMPONENT, - }, - out: { - componentsDir, - indexFileName, - }, - widgetsPackage: WIDGETS_PACKAGE, - typeGenerationOptions: { - generateReexports: true, - generateCustomTypes: true, - }, - templatingOptions: { - quotes: options.quotes ?? 'double', - excplicitIndexInImports: options.explicitIndexInImports ?? true, - }, - unifiedConfig: frameworkConfig, - componentGeneratorTplConfig: options.componentGeneratorTplConfig, - }; -} - -async function executeGeneration( - generateComponents: any, - config: GenerationConfig, - metaData: any, - componentsDir: string, - indexFileName: string, - framework: Framework, -): Promise { - const messages = createMessages(framework); - const handler = getFrameworkHandler(framework); - - logger.verbose(messages.generating); - - await handler.executeGeneration(generateComponents, config, metaData); - - logger.verbose(MSG_GENERATION_COMPLETED); - - if (fs.existsSync(indexFileName)) { - const indexContent = fs.readFileSync(indexFileName, ENCODING_UTF8); - const exportCount = (indexContent.match(EXPORT_PATTERN) || []).length; - logger.verbose(` Exports: ${exportCount}`); - } - - if (fs.existsSync(componentsDir)) { - const dirCount = fs - .readdirSync(componentsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && entry.name !== CORE_DIR).length; - logger.verbose(` Component Directories: ${dirCount}`); - } - - logger.verbose(messages.generationSuccess); -} - -const runExecutor: PromiseExecutor = async ( - options, - context, -) => { - const absoluteProjectRoot = resolveProjectPath(context); - const workspaceRoot = context.root; - - const framework: Framework = options.framework || 'react'; - const messages = createMessages(framework); - - logger.verbose(messages.starting); - const projectRelativePath = path.relative(workspaceRoot, absoluteProjectRoot) || DOT_SLASH_PREFIX; - logger.verbose(` Project root: ${projectRelativePath}`); - logger.verbose(` Framework: ${framework}`); - - try { - const componentsDir = path.resolve( - absoluteProjectRoot, - options.componentsDir || DEFAULT_COMPONENTS_DIR, - ); - const indexFileName = path.resolve( - absoluteProjectRoot, - options.indexFileName || DEFAULT_INDEX_FILE_NAME, - ); - - const metadataPath = resolveMetadataPath(options, absoluteProjectRoot, workspaceRoot); - const metaData = loadMetadata(metadataPath); - - const handler = getFrameworkHandler(framework); - const configName = options.generatorConfig || handler.getDefaults().configName; - const frameworkConfig = loadFrameworkConfig( - workspaceRoot, - absoluteProjectRoot, - configName, - framework, - ); - - const generateComponents = loadGenerationFunction(framework); - - const config = buildGenerationConfig(options, componentsDir, indexFileName, frameworkConfig); - - await executeGeneration( - generateComponents, - config, - metaData, - componentsDir, - indexFileName, - framework, - ); - - return { success: true }; - } catch (error) { - logError(messages.generationFailed, error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './generate-components.impl'; diff --git a/packages/nx-infra-plugin/src/executors/generate-components/generate-components.impl.ts b/packages/nx-infra-plugin/src/executors/generate-components/generate-components.impl.ts new file mode 100644 index 000000000000..70f8306069cb --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/generate-components/generate-components.impl.ts @@ -0,0 +1,351 @@ +import { logger } from '@nx/devkit'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createExecutor } from '../../utils/create-executor'; +import { getErrorMessage } from '../../utils/error-handler'; +import { GenerateReactComponentsExecutorSchema, Framework } from './schema'; +import { getFrameworkHandler, GenerationConfig } from './framework-handlers'; + +const DEFAULT_COMPONENTS_DIR = './src'; +const DEFAULT_INDEX_FILE_NAME = './src/index.ts'; +const CORE_DIR = 'core'; + +const TOOLS_DIR = 'tools'; +const GENERATORS_CONFIG_FILE = 'generators-config.js'; +const METADATA_PACKAGE = 'devextreme-metadata'; +const METADATA_FILE = 'integration-data.json'; +const INTERNAL_TOOLS_PACKAGE = 'devextreme-internal-tools'; + +const DEFAULT_BASE_COMPONENT = './core/component'; +const DEFAULT_EXTENSION_COMPONENT = './core/extension-component'; +const DEFAULT_CONFIG_COMPONENT = './core/nested-option'; + +const WIDGETS_PACKAGE = 'devextreme'; + +const MSG_LOADING_METADATA = '📋 Loading metadata'; +const MSG_GENERATORS_CONFIG_NOT_FOUND = + '⚠️ generators-config.js not found, proceeding without unifiedConfig'; +const MSG_GENERATION_COMPLETED = '✓ Component generation completed'; + +const ERROR_METADATA_NOT_FOUND = + 'Could not find devextreme-metadata/integration-data.json. Please ensure devextreme-metadata is installed or provide a metadataPath option.'; + +const PARENT_DIR_PREFIX = '../'; +const DOT_SLASH_PREFIX = './'; + +const ENCODING_UTF8 = 'utf-8'; + +const EXPORT_PATTERN = /export \{/g; + +interface FrameworkMessages { + loadedConfig: string; + generating: string; + generationSuccess: string; + starting: string; + generationFailed: string; +} + +function createMessages(framework: Framework): FrameworkMessages { + const frameworkName = framework.charAt(0).toUpperCase() + framework.slice(1); + return { + loadedConfig: `✓ Loaded ${frameworkName} configuration from generators-config.js`, + generating: `⚙️ Generating ${frameworkName} components`, + generationSuccess: `✨ ${frameworkName} component generation successful!`, + starting: `🔧 Starting ${frameworkName} component generation`, + generationFailed: `❌ ${frameworkName} component generation failed`, + }; +} + +function resolveDefaultMetadataPath(): string { + try { + return require.resolve(`${METADATA_PACKAGE}/${METADATA_FILE}`); + } catch { + throw new Error(ERROR_METADATA_NOT_FOUND); + } +} + +function resolveCustomMetadataPath( + metadataPath: string, + absoluteProjectRoot: string, + workspaceRoot: string, +): string { + const relativeToProject = path.resolve(absoluteProjectRoot, metadataPath); + if (fs.existsSync(relativeToProject)) { + return relativeToProject; + } + + const relativeToWorkspace = path.resolve(workspaceRoot, metadataPath); + if (fs.existsSync(relativeToWorkspace)) { + return relativeToWorkspace; + } + + if (metadataPath.startsWith(PARENT_DIR_PREFIX)) { + return path.resolve(workspaceRoot, metadataPath); + } + + return relativeToProject; +} + +function resolveMetadataPath( + options: GenerateReactComponentsExecutorSchema, + absoluteProjectRoot: string, + workspaceRoot: string, +): string { + if (options.metadataPath) { + return resolveCustomMetadataPath(options.metadataPath, absoluteProjectRoot, workspaceRoot); + } + + return resolveDefaultMetadataPath(); +} + +function loadMetadata(metadataPath: string): any { + logger.verbose(MSG_LOADING_METADATA); + logger.verbose(` Path: ${metadataPath}`); + + if (!fs.existsSync(metadataPath)) { + throw new Error(`Metadata file not found: ${metadataPath}`); + } + + const metadataContent = fs.readFileSync(metadataPath, ENCODING_UTF8); + const metaData = JSON.parse(metadataContent); + + const widgetCount = Object.keys(metaData.Widgets || {}).length; + logger.verbose(`✓ Loaded ${widgetCount} widget definitions`); + + return metaData; +} + +function loadConfigFromFile(projectRoot: string, configPath: string, framework: Framework): any { + const absoluteConfigPath = path.resolve(projectRoot, configPath); + + if (!fs.existsSync(absoluteConfigPath)) { + logger.warn(`⚠️ Configuration file not found: ${configPath}`); + return undefined; + } + + try { + delete require.cache[require.resolve(absoluteConfigPath)]; + const config = require(absoluteConfigPath); + + const frameworkName = framework.charAt(0).toUpperCase() + framework.slice(1); + logger.verbose(`✓ Loaded ${frameworkName} configuration from ${configPath}`); + return config; + } catch (error) { + logger.warn(`⚠️ Could not load configuration from ${configPath}: ${getErrorMessage(error)}`); + return undefined; + } +} + +function loadConfigFromGeneratorsFile( + workspaceRoot: string, + configName: string, + framework: Framework, +): any { + const generatorsConfigPath = path.join(workspaceRoot, TOOLS_DIR, GENERATORS_CONFIG_FILE); + + if (!fs.existsSync(generatorsConfigPath)) { + logger.warn(MSG_GENERATORS_CONFIG_NOT_FOUND); + return undefined; + } + + try { + const generatorsConfig = require(generatorsConfigPath); + const config = generatorsConfig[configName]; + + if (!config) { + logger.warn(`⚠️ Configuration '${configName}' not found in generators-config.js`); + return undefined; + } + + const messages = createMessages(framework); + logger.verbose(messages.loadedConfig); + return config; + } catch (error) { + logger.warn(`⚠️ Could not load generators-config.js: ${getErrorMessage(error)}`); + return undefined; + } +} + +function loadFrameworkConfig( + workspaceRoot: string, + projectRoot: string, + configName: string, + framework: Framework, +): any { + const isFilePath = + configName.startsWith(DOT_SLASH_PREFIX) || configName.startsWith(PARENT_DIR_PREFIX); + + if (isFilePath) { + return loadConfigFromFile(projectRoot, configName, framework); + } + + return loadConfigFromGeneratorsFile(workspaceRoot, configName, framework); +} + +function loadGenerationFunction(framework: Framework): any { + if (framework === 'angular') { + return null; + } + + const handler = getFrameworkHandler(framework); + const functionName = handler.getDefaults().generationFunctionName; + + try { + const internalTools = require(INTERNAL_TOOLS_PACKAGE); + const generationFunction = internalTools[functionName]; + + if (!generationFunction) { + throw new Error( + `Generation function '${functionName}' not found in ${INTERNAL_TOOLS_PACKAGE}`, + ); + } + + return generationFunction; + } catch (error) { + throw new Error( + `Could not load ${functionName} from devextreme-internal-tools. Please ensure devextreme-internal-tools is installed as a dependency. Error: ${getErrorMessage( + error, + )}`, + ); + } +} + +function buildGenerationConfig( + options: GenerateReactComponentsExecutorSchema, + componentsDir: string, + indexFileName: string, + frameworkConfig: any, +): GenerationConfig { + return { + metaData: undefined, + components: { + baseComponent: options.baseComponent || DEFAULT_BASE_COMPONENT, + extensionComponent: options.extensionComponent || DEFAULT_EXTENSION_COMPONENT, + configComponent: options.configComponent || DEFAULT_CONFIG_COMPONENT, + }, + out: { + componentsDir, + indexFileName, + }, + widgetsPackage: WIDGETS_PACKAGE, + typeGenerationOptions: { + generateReexports: true, + generateCustomTypes: true, + }, + templatingOptions: { + quotes: options.quotes ?? 'double', + excplicitIndexInImports: options.explicitIndexInImports ?? true, + }, + unifiedConfig: frameworkConfig, + componentGeneratorTplConfig: options.componentGeneratorTplConfig, + }; +} + +async function executeGeneration( + generateComponents: any, + config: GenerationConfig, + metaData: any, + componentsDir: string, + indexFileName: string, + framework: Framework, +): Promise { + const messages = createMessages(framework); + const handler = getFrameworkHandler(framework); + + logger.verbose(messages.generating); + + await handler.executeGeneration(generateComponents, config, metaData); + + logger.verbose(MSG_GENERATION_COMPLETED); + + if (fs.existsSync(indexFileName)) { + const indexContent = fs.readFileSync(indexFileName, ENCODING_UTF8); + const exportCount = (indexContent.match(EXPORT_PATTERN) || []).length; + logger.verbose(` Exports: ${exportCount}`); + } + + if (fs.existsSync(componentsDir)) { + const dirCount = fs + .readdirSync(componentsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== CORE_DIR).length; + logger.verbose(` Component Directories: ${dirCount}`); + } + + logger.verbose(messages.generationSuccess); +} + +interface ResolvedGenerateComponents { + projectRoot: string; + workspaceRoot: string; + framework: Framework; + componentsDir: string; + indexFileName: string; + metadataPath: string; + configName: string; +} + +export default createExecutor({ + name: 'GenerateComponents', + resolve: (options, { projectRoot, context }) => { + const workspaceRoot = context.root; + const framework: Framework = options.framework || 'react'; + const messages = createMessages(framework); + + logger.verbose(messages.starting); + const projectRelativePath = path.relative(workspaceRoot, projectRoot) || DOT_SLASH_PREFIX; + logger.verbose(` Project root: ${projectRelativePath}`); + logger.verbose(` Framework: ${framework}`); + + const componentsDir = path.resolve( + projectRoot, + options.componentsDir || DEFAULT_COMPONENTS_DIR, + ); + const indexFileName = path.resolve( + projectRoot, + options.indexFileName || DEFAULT_INDEX_FILE_NAME, + ); + + const metadataPath = resolveMetadataPath(options, projectRoot, workspaceRoot); + + const handler = getFrameworkHandler(framework); + const configName = options.generatorConfig || handler.getDefaults().configName; + + return { + projectRoot, + workspaceRoot, + framework, + componentsDir, + indexFileName, + metadataPath, + configName, + }; + }, + run: async (resolved, options) => { + const metaData = loadMetadata(resolved.metadataPath); + + const frameworkConfig = loadFrameworkConfig( + resolved.workspaceRoot, + resolved.projectRoot, + resolved.configName, + resolved.framework, + ); + + const generateComponents = loadGenerationFunction(resolved.framework); + + const config = buildGenerationConfig( + options, + resolved.componentsDir, + resolved.indexFileName, + frameworkConfig, + ); + + await executeGeneration( + generateComponents, + config, + metaData, + resolved.componentsDir, + resolved.indexFileName, + resolved.framework, + ); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/karma-multi-env/executor.ts b/packages/nx-infra-plugin/src/executors/karma-multi-env/executor.ts index 37b4969ced3f..05936e0c1d99 100644 --- a/packages/nx-infra-plugin/src/executors/karma-multi-env/executor.ts +++ b/packages/nx-infra-plugin/src/executors/karma-multi-env/executor.ts @@ -1,654 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import { KarmaMultiEnvExecutorSchema } from './schema'; -import { - loadKarmaModule, - createKarmaServer, - KarmaConfigOptions, - KarmaExecutorError, -} from './karma-utils'; -import { resolveProjectPath } from '../../utils/path-resolver'; -import { KarmaEnvironment, ENVIRONMENT_CONFIGS, DEFAULT_ENVIRONMENTS } from './karma.types'; - -const SIGNALS = { SIGINT: 'SIGINT', SIGTERM: 'SIGTERM' } as const; -const TIMEOUTS = { DEFAULT: 300000, FORCE_KILL_DELAY: 1000 } as const; -const STATUS_ICONS = { - SUCCESS: '✅', - FAILURE: '❌', - CELEBRATION: '🎉', - ERROR: '💥', - WATCH: '👁️', - START: '🚀', - STOP: '📴', - REFRESH: '🔄', - DOCUMENTATION: '📋', - CLOCK: '⏱️', - DEBUG: '🕵🏻‍♂️', -} as const; - -interface TestResult { - environment: KarmaEnvironment; - success: boolean; - exitCode?: number; - duration: number; - error?: string; - message?: string; -} - -interface ExecutionPlan { - executionOrder: KarmaEnvironment[]; - timeout: number; -} - -interface TestSummary { - totalDuration: number; - results: TestResult[]; - environmentsRun: KarmaEnvironment[]; - summary: { - total: number; - passed: number; - failed: number; - }; - exitCode?: number; -} - -const createErrorHandler = (environment: string) => ({ - logError: (message: string, error?: any) => { - logger.error(`[${environment.toUpperCase()}] ${message}`); - if (error?.message) logger.error(`Details: ${error.message}`); - }, - - createErrorResult: (error: any, duration: number): TestResult => ({ - environment: environment as KarmaEnvironment, - success: false, - exitCode: 1, - duration, - error: error instanceof Error ? error.message : String(error), - message: error instanceof Error ? error.message : String(error), - }), - - logWarning: (message: string, error?: any) => { - logger.warn(`[${environment.toUpperCase()}] ${message}`); - if (error?.message) logger.warn(`Details: ${error.message}`); - }, -}); - -function planTestExecution( - options: KarmaMultiEnvExecutorSchema, - environments: KarmaEnvironment[], -): ExecutionPlan { - const executionOrder = createExecutionOrder(environments, options.watch || false); - return { - executionOrder, - timeout: options.timeout || TIMEOUTS.DEFAULT, - }; -} - -function createTestConfig( - baseConfig: KarmaConfigOptions, - shimPath: string, - options: KarmaMultiEnvExecutorSchema, -): KarmaConfigOptions { - const isSingleRun = !options.debug && !options.watch; - - const config = { - ...baseConfig, - files: [{ pattern: shimPath, watched: false }], - preprocessors: { [shimPath]: ['webpack'] }, - singleRun: isSingleRun, - autoWatch: options.watch || false, - plugins: baseConfig.plugins, - browsers: - options.watch || options.debug ? ['Chrome'] : baseConfig.browsers || ['ChromeHeadless'], - logLevel: baseConfig.logLevel || 'info', - }; - - return config; -} - -function createExecutionOrder( - environments: KarmaEnvironment[], - watch: boolean, -): KarmaEnvironment[] { - if (watch) { - return ['client']; - } - - const order: KarmaEnvironment[] = []; - const validEnvironments: KarmaEnvironment[] = ['client', 'server', 'hydration']; - - for (const env of validEnvironments) { - if (environments.includes(env)) { - order.push(env); - } - } - - return order; -} - -function summarizeTestResults(results: TestResult[]): TestSummary { - const totalDuration = results.reduce((sum, result) => sum + result.duration, 0); - - return { - totalDuration, - results, - environmentsRun: results.map((r) => r.environment), - summary: { - total: results.length, - passed: results.filter((r) => r.success).length, - failed: results.filter((r) => !r.success).length, - }, - ...(results.some((r) => !r.success) - ? { - exitCode: results.find((r) => !r.success)?.exitCode || 1, - } - : {}), - }; -} - -async function executeSingleRun( - environment: KarmaEnvironment, - config: KarmaConfigOptions, - timeout: number, -): Promise { - const errorHandler = createErrorHandler(environment); - const startTime = Date.now(); - - return new Promise((resolve) => { - let hasCompleted = false; - let timeoutId: NodeJS.Timeout | null = null; - let server: any = null; - let serverProcess: any = null; - - const completeOnce = (result: TestResult) => { - if (hasCompleted) { - errorHandler.logWarning( - `Attempted to complete test multiple times, ignoring subsequent calls`, - ); - return; - } - hasCompleted = true; - - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - - resolve(result); - }; - - const forceServerCleanup = () => { - try { - if (server) { - logger.debug(`[${environment.toUpperCase()}] Stopping Karma server...`); - server.stop(); - server = null; - } - - if (serverProcess && !serverProcess.killed) { - logger.debug(`[${environment.toUpperCase()}] Force terminating server process...`); - serverProcess.kill(SIGNALS.SIGTERM); - setTimeout(() => { - if (!serverProcess.killed) { - serverProcess.kill('SIGKILL'); - } - }, TIMEOUTS.FORCE_KILL_DELAY); - } - } catch (cleanupError) { - errorHandler.logWarning('Error during server cleanup', cleanupError); - } - }; - - const createKarmaCallback = () => { - return (exitCode: number) => { - const duration = Date.now() - startTime; - - logger.verbose( - `[${environment.toUpperCase()}] Karma callback called with exit code: ${exitCode}`, - ); - - if (hasCompleted) { - errorHandler.logWarning('Callback already completed, ignoring'); - return; - } - - forceServerCleanup(); - - const testResult = - exitCode === 0 - ? { - environment, - success: true, - duration, - } - : { - environment, - success: false, - exitCode, - duration, - error: `[${environment.toUpperCase()}] Tests failed with exit code ${exitCode}`, - message: `[${environment.toUpperCase()}] Tests failed with exit code ${exitCode}`, - }; - - if (testResult.success) { - logger.verbose(`\n[${environment.toUpperCase()}] Tests completed successfully`); - } else { - errorHandler.logError(testResult.error!); - } - - completeOnce(testResult); - }; - }; - - const createTimeoutHandler = (): NodeJS.Timeout => { - return setTimeout(() => { - if (hasCompleted) return; - - errorHandler.logError(`Tests timed out after ${timeout}ms`); - forceServerCleanup(); - - const duration = Date.now() - startTime; - const timeoutResult = { - environment, - success: false, - exitCode: 1, - duration, - error: `${environment} tests timed out after ${timeout}ms`, - message: `${environment} tests timed out after ${timeout}ms`, - }; - completeOnce(timeoutResult); - }, timeout); - }; - - const setupServerEvents = (server: any): void => { - if (!server.on || typeof server.on !== 'function') return; - - server.on('browsers_ready', () => { - logger.debug(`[${environment.toUpperCase()}] Browsers ready`); - }); - - server.on('run_complete', (_browsers: any, results: any) => { - logger.debug(`[${environment.toUpperCase()}] Run complete. Success: ${results.success}`); - }); - }; - - const captureServerProcess = (): void => { - try { - const karmaModule = require('karma'); - if (karmaModule && server._boundServer) { - serverProcess = server._boundServer; - } - } catch (e) { - logger.debug( - `[${environment.toUpperCase()}] Could not capture server process: ${e.message}`, - ); - } - }; - - try { - const karmaCallback = createKarmaCallback(); - server = createKarmaServer(config, karmaCallback); - timeoutId = createTimeoutHandler(); - setupServerEvents(server); - captureServerProcess(); - - logger.debug(`[${environment.toUpperCase()}] Starting Karma server with PID: ${process.pid}`); - server.start(); - } catch (error) { - if (!hasCompleted) { - if (timeoutId) clearTimeout(timeoutId); - forceServerCleanup(); - - const duration = Date.now() - startTime; - errorHandler.logError('Failed to start Karma server', error); - completeOnce(errorHandler.createErrorResult(error, duration)); - } - } - }); -} - -const createWatchModeCallback = ( - environment: KarmaEnvironment, - startTime: number, - server: any, - resolve: (result: TestResult) => void, -): ((exitCode: number) => void) => { - return (exitCode: number) => { - const duration = Date.now() - startTime; - const errorHandler = createErrorHandler(environment); - - try { - if (server && server.stop) { - logger.verbose(`[${environment.toUpperCase()}] Stopping Karma server...`); - server.stop(); - } - } catch (cleanupError) { - errorHandler.logWarning('Error cleaning up Karma server'); - } - - const result: TestResult = { - environment, - success: exitCode === 0, - exitCode, - duration, - ...(exitCode !== 0 && { - error: `Tests failed with exit code ${exitCode}`, - message: `Tests failed with exit code ${exitCode}`, - }), - }; - - resolve(result); - }; -}; - -const setupSignalHandlers = (server: any): void => { - const handleExit = (signal: string) => { - logger.verbose(`\n${STATUS_ICONS.STOP} Received ${signal} - stopping watch mode...`); - if (server && server.stop) { - server.stop(); - } - process.exit(0); - }; - - process.on(SIGNALS.SIGINT, () => handleExit(SIGNALS.SIGINT)); - process.on(SIGNALS.SIGTERM, () => handleExit(SIGNALS.SIGTERM)); -}; - -async function loadKarmaConfig( - projectRoot: string, - karmaConfigPath: string, - config?: any, -): Promise { - try { - const karma = loadKarmaModule(); - const absoluteConfigPath = path.join(projectRoot, karmaConfigPath); - const resultConfig = await karma.config.parseConfig(absoluteConfigPath, config ?? {}); - - return resultConfig; - } catch (error) { - throw new KarmaExecutorError(`Failed to load Karma configuration from ${karmaConfigPath}`, { - projectRoot, - karmaConfigPath, - originalError: error instanceof Error ? error.message : String(error), - }); - } -} - -type ExecutionMode = 'watch' | 'single' | 'debug'; - -const getExecutionMode = (options: KarmaMultiEnvExecutorSchema): ExecutionMode => { - if (options.watch) { - return 'watch'; - } - - if (options.debug) { - return 'debug'; - } - - return 'single'; -}; - -const shouldStopExecution = (result: TestResult, isWatchMode: boolean): boolean => - !isWatchMode && !result.success; - -const createExecutionResult = ( - _results: TestResult[], - hasFailures: boolean, - summary: TestSummary, -) => ({ - success: !hasFailures, - result: summary, -}); - -const logExecutionStart = (plan: ExecutionPlan, options: KarmaMultiEnvExecutorSchema): void => { - if (options.watch) return; - - logger.verbose(`Running tests in environments: ${plan.executionOrder.join(', ')}`); - if (options.verbose) { - logger.verbose(`Karma config: ${options.karmaConfig}`); - logger.verbose(`Timeout: ${plan.timeout}ms`); - } -}; - -const logEnvironmentStart = (environment: KarmaEnvironment): void => - logger.verbose(`\n[${environment.toUpperCase()}] Starting tests...`); - -const logWatchModeStart = (environment: KarmaEnvironment): void => - logger.verbose(`[${environment.toUpperCase()}] Watch mode enabled - starting Karma server...`); - -const logTestResults = ( - summary: TestSummary, - plan: ExecutionPlan, - options: KarmaMultiEnvExecutorSchema, -): void => { - if (options.watch) { - logger.verbose( - `\n${STATUS_ICONS.WATCH} Watch mode active for: ${plan.executionOrder.join(', ')}`, - ); - if (options.verbose) { - logger.verbose(`Karma config: ${options.karmaConfig}`); - logger.verbose('Watching file changes...'); - } - logger.verbose('Press CTRL+C to stop watching...'); - return; - } - - logger.verbose('\n' + '='.repeat(50)); - logger.verbose(`${STATUS_ICONS.DOCUMENTATION} TEST RESULTS SUMMARY`); - logger.verbose('='.repeat(50)); - logger.verbose( - `\n${STATUS_ICONS.SUCCESS} Environments tested: ${plan.executionOrder.join(', ')}`, - ); - logger.verbose(`${STATUS_ICONS.CLOCK} Total duration: ${summary.totalDuration}ms`); - - summary.results.forEach((result) => { - const statusIcon = result.success ? STATUS_ICONS.SUCCESS : STATUS_ICONS.FAILURE; - const durationText = `${result.duration}ms`; - const statusText = result.success ? 'PASS' : 'FAIL'; - - logger.verbose( - `\n${statusIcon} ${result.environment.toUpperCase()}: ${statusText} (${durationText})`, - ); - if (!result.success && result.error) { - logger.error(` Error: ${result.error}`); - } - }); - - if (summary.summary.failed === 0) { - logger.verbose(`\n${STATUS_ICONS.CELEBRATION} SUCCESS: All tests passed`); - } else { - logger.error(`\n${STATUS_ICONS.ERROR} FAILURE: Some tests failed`); - } -}; - -const setupWatchModeEvents = (environment: KarmaEnvironment, server: any): void => { - if (!server.on || typeof server.on !== 'function') return; - - server.on('browsers_ready', () => { - logger.verbose( - `\n${STATUS_ICONS.WATCH} Watch mode active - browsers ready and watching for file changes...`, - ); - }); - - server.on('run_complete', (_browsers: any, results: any) => { - const statusIcon = results.success ? STATUS_ICONS.SUCCESS : STATUS_ICONS.FAILURE; - const statusText = results.success ? 'All tests passed' : 'Some tests failed'; - - logger.verbose( - `\n[${environment.toUpperCase()}] Test run completed. Success: ${results.success}`, - ); - logger.verbose(`${statusIcon} ${statusText} in watch mode - continuing to watch...`); - logger.verbose('Press CTRL+C to stop watching...'); - }); - - server.on('file_list_modified', () => { - logger.verbose(`\n${STATUS_ICONS.REFRESH} File changes detected, re-running tests...`); - }); -}; - -async function executeWatchMode( - environment: KarmaEnvironment, - config: KarmaConfigOptions, - _projectRoot: string, -): Promise { - const startTime = Date.now(); - - return new Promise((resolve) => { - const server = createKarmaServer(config, (exitCode: number) => { - const callback = createWatchModeCallback(environment, startTime, server, resolve); - callback(exitCode); - }); - - setupWatchModeEvents(environment, server); - setupSignalHandlers(server); - - logger.verbose(`\n${STATUS_ICONS.START} Starting Karma server in watch mode...`); - server.start(); - }); -} - -const handleWatchMode = async ( - options: KarmaMultiEnvExecutorSchema, - _context: any, - projectRoot: string, -): Promise<{ success: boolean; result?: TestSummary; error?: string }> => { - try { - const baseConfig = await loadKarmaConfig(projectRoot, options.karmaConfig); - const plan = planTestExecution(options, ['client']); - const envConfig = ENVIRONMENT_CONFIGS['client']; - const shimPath = path.join(projectRoot, envConfig.shimPath); - const config = createTestConfig(baseConfig, shimPath, options); - - logWatchModeStart('client'); - const result = await executeWatchMode('client', config, projectRoot); - const summary = summarizeTestResults([result]); - - logTestResults(summary, plan, options); - return { success: result.success, result: summary }; - } catch (error) { - logger.error(`\n${STATUS_ICONS.ERROR} Test execution failed: ${error.message}`); - return { success: false, error: error.message }; - } -}; - -const setupDebugModeEvents = (environment: KarmaEnvironment, server: any): void => { - if (!server.on || typeof server.on !== 'function') return; - - server.on('browsers_ready', () => { - logger.verbose( - `\n${STATUS_ICONS.DEBUG} Debug mode for the ${environment} environment is active. Click the "DEBUG" button in the opened browser window to start debugging.`, - ); - logger.verbose('Press CTRL+C to stop debugging...'); - }); -}; - -async function launchDebugMode( - environment: KarmaEnvironment, - config: KarmaConfigOptions, - _projectRoot: string, -): Promise { - return new Promise((resolve) => { - const server = createKarmaServer(config, (exitCode: number) => { - resolve({ success: exitCode === 0, environment, duration: 0 }); - }); - - setupDebugModeEvents(environment, server); - setupSignalHandlers(server); - - logger.verbose(`\n${STATUS_ICONS.START} Starting Karma server in debug mode...`); - server.start(); - }); -} - -const handleDebugMode = async ( - options: KarmaMultiEnvExecutorSchema, - _context: any, - projectRoot: string, - environments: KarmaEnvironment[], -): Promise<{ success: boolean; result?: TestSummary; error?: string }> => { - try { - const baseConfig = await loadKarmaConfig(projectRoot, options.karmaConfig); - const envConfig = ENVIRONMENT_CONFIGS[environments[0]]; - const shimPath = path.join(projectRoot, envConfig.shimPath); - const config = createTestConfig(baseConfig, shimPath, options); - - const result = await launchDebugMode(environments[0], config, projectRoot); - - return { success: result.success }; - } catch (error) { - logger.error(`\n${STATUS_ICONS.ERROR} Test execution failed: ${error.message}`); - return { success: false, error: error.message }; - } -}; - -const handleSingleExecution = async ( - options: KarmaMultiEnvExecutorSchema, - _context: any, - projectRoot: string, - environments: KarmaEnvironment[], -): Promise<{ success: boolean; result?: TestSummary; error?: string }> => { - try { - const baseConfig = await loadKarmaConfig(projectRoot, options.karmaConfig); - const plan = planTestExecution(options, environments); - const results: TestResult[] = []; - - logExecutionStart(plan, options); - - for (const environment of plan.executionOrder) { - const envConfig = ENVIRONMENT_CONFIGS[environment]; - const shimPath = path.join(projectRoot, envConfig.shimPath); - - logEnvironmentStart(environment); - - try { - const config = createTestConfig(baseConfig, shimPath, options); - const result = await executeSingleRun(environment, config, plan.timeout); - results.push(result); - - if (shouldStopExecution(result, false)) { - logger.error(`\n[${environment.toUpperCase()}] Stopping execution after test failure`); - break; - } - } catch (error) { - const errorHandler = createErrorHandler(environment); - results.push(errorHandler.createErrorResult(error, 0)); - logger.error(`\n[${environment.toUpperCase()}] Stopping execution after error`); - break; - } - } - - const summary = summarizeTestResults(results); - logTestResults(summary, plan, options); - - return createExecutionResult(results, summary.summary.failed > 0, summary); - } catch (error) { - logger.error(`\n${STATUS_ICONS.ERROR} Test execution failed: ${error.message}`); - return { success: false, error: error.message }; - } -}; - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - const environments = options.environments || DEFAULT_ENVIRONMENTS; - - const executionMode = getExecutionMode(options); - if (executionMode === 'watch') { - return await handleWatchMode(options, context, projectRoot); - } - - if (executionMode === 'debug') { - if (options.environments && options.environments.length > 1) { - logger.error( - `Debug mode supports only a single environment, please specify one environment. Current environments: ${options.environments.join(', ')}`, - ); - - return Promise.resolve({ success: false }); - } - - return await handleDebugMode(options, context, projectRoot, environments); - } - - return await handleSingleExecution(options, context, projectRoot, environments); -}; - -export default runExecutor; +export { default } from './karma-multi-env.impl'; diff --git a/packages/nx-infra-plugin/src/executors/karma-multi-env/karma-multi-env.impl.ts b/packages/nx-infra-plugin/src/executors/karma-multi-env/karma-multi-env.impl.ts new file mode 100644 index 000000000000..1f5805c029e6 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/karma-multi-env/karma-multi-env.impl.ts @@ -0,0 +1,661 @@ +import { logger } from '@nx/devkit'; +import * as path from 'path'; +import { createExecutor } from '../../utils/create-executor'; +import { KarmaMultiEnvExecutorSchema } from './schema'; +import { + loadKarmaModule, + createKarmaServer, + KarmaConfigOptions, + KarmaExecutorError, +} from './karma-utils'; +import { KarmaEnvironment, ENVIRONMENT_CONFIGS, DEFAULT_ENVIRONMENTS } from './karma.types'; + +const SIGNALS = { SIGINT: 'SIGINT', SIGTERM: 'SIGTERM' } as const; +const TIMEOUTS = { DEFAULT: 300000, FORCE_KILL_DELAY: 1000 } as const; +const STATUS_ICONS = { + SUCCESS: '✅', + FAILURE: '❌', + CELEBRATION: '🎉', + ERROR: '💥', + WATCH: '👁️', + START: '🚀', + STOP: '📴', + REFRESH: '🔄', + DOCUMENTATION: '📋', + CLOCK: '⏱️', + DEBUG: '🕵🏻‍♂️', +} as const; + +interface TestResult { + environment: KarmaEnvironment; + success: boolean; + exitCode?: number; + duration: number; + error?: string; + message?: string; +} + +interface ExecutionPlan { + executionOrder: KarmaEnvironment[]; + timeout: number; +} + +interface TestSummary { + totalDuration: number; + results: TestResult[]; + environmentsRun: KarmaEnvironment[]; + summary: { + total: number; + passed: number; + failed: number; + }; + exitCode?: number; +} + +const createErrorHandler = (environment: string) => ({ + logError: (message: string, error?: any) => { + logger.error(`[${environment.toUpperCase()}] ${message}`); + if (error?.message) logger.error(`Details: ${error.message}`); + }, + + createErrorResult: (error: any, duration: number): TestResult => ({ + environment: environment as KarmaEnvironment, + success: false, + exitCode: 1, + duration, + error: error instanceof Error ? error.message : String(error), + message: error instanceof Error ? error.message : String(error), + }), + + logWarning: (message: string, error?: any) => { + logger.warn(`[${environment.toUpperCase()}] ${message}`); + if (error?.message) logger.warn(`Details: ${error.message}`); + }, +}); + +function createExecutionOrder( + environments: KarmaEnvironment[], + watch: boolean, +): KarmaEnvironment[] { + if (watch) { + return ['client']; + } + + const order: KarmaEnvironment[] = []; + const validEnvironments: KarmaEnvironment[] = ['client', 'server', 'hydration']; + + for (const environment of validEnvironments) { + if (environments.includes(environment)) { + order.push(environment); + } + } + + return order; +} + +function planTestExecution( + options: KarmaMultiEnvExecutorSchema, + environments: KarmaEnvironment[], +): ExecutionPlan { + const executionOrder = createExecutionOrder(environments, options.watch || false); + return { + executionOrder, + timeout: options.timeout || TIMEOUTS.DEFAULT, + }; +} + +function createTestConfig( + baseConfig: KarmaConfigOptions, + shimPath: string, + options: KarmaMultiEnvExecutorSchema, +): KarmaConfigOptions { + const isSingleRun = !options.debug && !options.watch; + + const config = { + ...baseConfig, + files: [{ pattern: shimPath, watched: false }], + preprocessors: { [shimPath]: ['webpack'] }, + singleRun: isSingleRun, + autoWatch: options.watch || false, + plugins: baseConfig.plugins, + browsers: + options.watch || options.debug ? ['Chrome'] : baseConfig.browsers || ['ChromeHeadless'], + logLevel: baseConfig.logLevel || 'info', + }; + + return config; +} + +function summarizeTestResults(results: TestResult[]): TestSummary { + const totalDuration = results.reduce((sum, result) => sum + result.duration, 0); + + return { + totalDuration, + results, + environmentsRun: results.map((result) => result.environment), + summary: { + total: results.length, + passed: results.filter((result) => result.success).length, + failed: results.filter((result) => !result.success).length, + }, + ...(results.some((result) => !result.success) + ? { + exitCode: results.find((result) => !result.success)?.exitCode || 1, + } + : {}), + }; +} + +async function executeSingleRun( + environment: KarmaEnvironment, + config: KarmaConfigOptions, + timeout: number, +): Promise { + const errorHandler = createErrorHandler(environment); + const startTime = Date.now(); + + return new Promise((resolve) => { + let hasCompleted = false; + let timeoutId: NodeJS.Timeout | null = null; + let server: any = null; + let serverProcess: any = null; + + const completeOnce = (result: TestResult) => { + if (hasCompleted) { + errorHandler.logWarning( + `Attempted to complete test multiple times, ignoring subsequent calls`, + ); + return; + } + hasCompleted = true; + + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + + resolve(result); + }; + + const forceServerCleanup = () => { + try { + if (server) { + logger.debug(`[${environment.toUpperCase()}] Stopping Karma server...`); + server.stop(); + server = null; + } + + if (serverProcess && !serverProcess.killed) { + logger.debug(`[${environment.toUpperCase()}] Force terminating server process...`); + serverProcess.kill(SIGNALS.SIGTERM); + setTimeout(() => { + if (!serverProcess.killed) { + serverProcess.kill('SIGKILL'); + } + }, TIMEOUTS.FORCE_KILL_DELAY); + } + } catch (cleanupError) { + errorHandler.logWarning('Error during server cleanup', cleanupError); + } + }; + + const createKarmaCallback = () => { + return (exitCode: number) => { + const duration = Date.now() - startTime; + + logger.verbose( + `[${environment.toUpperCase()}] Karma callback called with exit code: ${exitCode}`, + ); + + if (hasCompleted) { + errorHandler.logWarning('Callback already completed, ignoring'); + return; + } + + forceServerCleanup(); + + const testResult = + exitCode === 0 + ? { + environment, + success: true, + duration, + } + : { + environment, + success: false, + exitCode, + duration, + error: `[${environment.toUpperCase()}] Tests failed with exit code ${exitCode}`, + message: `[${environment.toUpperCase()}] Tests failed with exit code ${exitCode}`, + }; + + if (testResult.success) { + logger.verbose(`\n[${environment.toUpperCase()}] Tests completed successfully`); + } else { + errorHandler.logError(testResult.error!); + } + + completeOnce(testResult); + }; + }; + + const createTimeoutHandler = (): NodeJS.Timeout => { + return setTimeout(() => { + if (hasCompleted) return; + + errorHandler.logError(`Tests timed out after ${timeout}ms`); + forceServerCleanup(); + + const duration = Date.now() - startTime; + const timeoutResult = { + environment, + success: false, + exitCode: 1, + duration, + error: `${environment} tests timed out after ${timeout}ms`, + message: `${environment} tests timed out after ${timeout}ms`, + }; + completeOnce(timeoutResult); + }, timeout); + }; + + const setupServerEvents = (target: any): void => { + if (!target.on || typeof target.on !== 'function') return; + + target.on('browsers_ready', () => { + logger.debug(`[${environment.toUpperCase()}] Browsers ready`); + }); + + target.on('run_complete', (_browsers: any, results: any) => { + logger.debug(`[${environment.toUpperCase()}] Run complete. Success: ${results.success}`); + }); + }; + + const captureServerProcess = (): void => { + try { + const karmaModule = require('karma'); + if (karmaModule && server._boundServer) { + serverProcess = server._boundServer; + } + } catch (captureError) { + logger.debug( + `[${environment.toUpperCase()}] Could not capture server process: ${captureError.message}`, + ); + } + }; + + try { + const karmaCallback = createKarmaCallback(); + server = createKarmaServer(config, karmaCallback); + timeoutId = createTimeoutHandler(); + setupServerEvents(server); + captureServerProcess(); + + logger.debug(`[${environment.toUpperCase()}] Starting Karma server with PID: ${process.pid}`); + server.start(); + } catch (error) { + if (!hasCompleted) { + if (timeoutId) clearTimeout(timeoutId); + forceServerCleanup(); + + const duration = Date.now() - startTime; + errorHandler.logError('Failed to start Karma server', error); + completeOnce(errorHandler.createErrorResult(error, duration)); + } + } + }); +} + +const createWatchModeCallback = ( + environment: KarmaEnvironment, + startTime: number, + server: any, + resolve: (result: TestResult) => void, +): ((exitCode: number) => void) => { + return (exitCode: number) => { + const duration = Date.now() - startTime; + const errorHandler = createErrorHandler(environment); + + try { + if (server && server.stop) { + logger.verbose(`[${environment.toUpperCase()}] Stopping Karma server...`); + server.stop(); + } + } catch (cleanupError) { + errorHandler.logWarning('Error cleaning up Karma server'); + } + + const result: TestResult = { + environment, + success: exitCode === 0, + exitCode, + duration, + ...(exitCode !== 0 && { + error: `Tests failed with exit code ${exitCode}`, + message: `Tests failed with exit code ${exitCode}`, + }), + }; + + resolve(result); + }; +}; + +const setupSignalHandlers = (server: any): void => { + const handleExit = (signal: string) => { + logger.verbose(`\n${STATUS_ICONS.STOP} Received ${signal} - stopping watch mode...`); + if (server && server.stop) { + server.stop(); + } + process.exit(0); + }; + + process.on(SIGNALS.SIGINT, () => handleExit(SIGNALS.SIGINT)); + process.on(SIGNALS.SIGTERM, () => handleExit(SIGNALS.SIGTERM)); +}; + +async function loadKarmaConfig( + projectRoot: string, + karmaConfigPath: string, + config?: any, +): Promise { + try { + const karma = loadKarmaModule(); + const absoluteConfigPath = path.join(projectRoot, karmaConfigPath); + const resultConfig = await karma.config.parseConfig(absoluteConfigPath, config ?? {}); + + return resultConfig; + } catch (error) { + throw new KarmaExecutorError(`Failed to load Karma configuration from ${karmaConfigPath}`, { + projectRoot, + karmaConfigPath, + originalError: error instanceof Error ? error.message : String(error), + }); + } +} + +type ExecutionMode = 'watch' | 'single' | 'debug'; + +const getExecutionMode = (options: KarmaMultiEnvExecutorSchema): ExecutionMode => { + if (options.watch) { + return 'watch'; + } + + if (options.debug) { + return 'debug'; + } + + return 'single'; +}; + +const shouldStopExecution = (result: TestResult, isWatchMode: boolean): boolean => + !isWatchMode && !result.success; + +const logExecutionStart = (plan: ExecutionPlan, options: KarmaMultiEnvExecutorSchema): void => { + if (options.watch) return; + + logger.verbose(`Running tests in environments: ${plan.executionOrder.join(', ')}`); + if (options.verbose) { + logger.verbose(`Karma config: ${options.karmaConfig}`); + logger.verbose(`Timeout: ${plan.timeout}ms`); + } +}; + +const logEnvironmentStart = (environment: KarmaEnvironment): void => + logger.verbose(`\n[${environment.toUpperCase()}] Starting tests...`); + +const logWatchModeStart = (environment: KarmaEnvironment): void => + logger.verbose(`[${environment.toUpperCase()}] Watch mode enabled - starting Karma server...`); + +const logTestResults = ( + summary: TestSummary, + plan: ExecutionPlan, + options: KarmaMultiEnvExecutorSchema, +): void => { + if (options.watch) { + logger.verbose( + `\n${STATUS_ICONS.WATCH} Watch mode active for: ${plan.executionOrder.join(', ')}`, + ); + if (options.verbose) { + logger.verbose(`Karma config: ${options.karmaConfig}`); + logger.verbose('Watching file changes...'); + } + logger.verbose('Press CTRL+C to stop watching...'); + return; + } + + logger.verbose('\n' + '='.repeat(50)); + logger.verbose(`${STATUS_ICONS.DOCUMENTATION} TEST RESULTS SUMMARY`); + logger.verbose('='.repeat(50)); + logger.verbose( + `\n${STATUS_ICONS.SUCCESS} Environments tested: ${plan.executionOrder.join(', ')}`, + ); + logger.verbose(`${STATUS_ICONS.CLOCK} Total duration: ${summary.totalDuration}ms`); + + summary.results.forEach((result) => { + const statusIcon = result.success ? STATUS_ICONS.SUCCESS : STATUS_ICONS.FAILURE; + const durationText = `${result.duration}ms`; + const statusText = result.success ? 'PASS' : 'FAIL'; + + logger.verbose( + `\n${statusIcon} ${result.environment.toUpperCase()}: ${statusText} (${durationText})`, + ); + if (!result.success && result.error) { + logger.error(` Error: ${result.error}`); + } + }); + + if (summary.summary.failed === 0) { + logger.verbose(`\n${STATUS_ICONS.CELEBRATION} SUCCESS: All tests passed`); + } else { + logger.error(`\n${STATUS_ICONS.ERROR} FAILURE: Some tests failed`); + } +}; + +const setupWatchModeEvents = (environment: KarmaEnvironment, server: any): void => { + if (!server.on || typeof server.on !== 'function') return; + + server.on('browsers_ready', () => { + logger.verbose( + `\n${STATUS_ICONS.WATCH} Watch mode active - browsers ready and watching for file changes...`, + ); + }); + + server.on('run_complete', (_browsers: any, results: any) => { + const statusIcon = results.success ? STATUS_ICONS.SUCCESS : STATUS_ICONS.FAILURE; + const statusText = results.success ? 'All tests passed' : 'Some tests failed'; + + logger.verbose( + `\n[${environment.toUpperCase()}] Test run completed. Success: ${results.success}`, + ); + logger.verbose(`${statusIcon} ${statusText} in watch mode - continuing to watch...`); + logger.verbose('Press CTRL+C to stop watching...'); + }); + + server.on('file_list_modified', () => { + logger.verbose(`\n${STATUS_ICONS.REFRESH} File changes detected, re-running tests...`); + }); +}; + +async function executeWatchMode( + environment: KarmaEnvironment, + config: KarmaConfigOptions, +): Promise { + const startTime = Date.now(); + + return new Promise((resolve) => { + const server = createKarmaServer(config, (exitCode: number) => { + const callback = createWatchModeCallback(environment, startTime, server, resolve); + callback(exitCode); + }); + + setupWatchModeEvents(environment, server); + setupSignalHandlers(server); + + logger.verbose(`\n${STATUS_ICONS.START} Starting Karma server in watch mode...`); + server.start(); + }); +} + +interface HandlerOutcome { + success: boolean; +} + +async function handleWatchMode( + options: KarmaMultiEnvExecutorSchema, + projectRoot: string, +): Promise { + try { + const baseConfig = await loadKarmaConfig(projectRoot, options.karmaConfig); + const plan = planTestExecution(options, ['client']); + const envConfig = ENVIRONMENT_CONFIGS['client']; + const shimPath = path.join(projectRoot, envConfig.shimPath); + const config = createTestConfig(baseConfig, shimPath, options); + + logWatchModeStart('client'); + const result = await executeWatchMode('client', config); + const summary = summarizeTestResults([result]); + + logTestResults(summary, plan, options); + return { success: result.success }; + } catch (error) { + logger.error(`\n${STATUS_ICONS.ERROR} Test execution failed: ${error.message}`); + return { success: false }; + } +} + +const setupDebugModeEvents = (environment: KarmaEnvironment, server: any): void => { + if (!server.on || typeof server.on !== 'function') return; + + server.on('browsers_ready', () => { + logger.verbose( + `\n${STATUS_ICONS.DEBUG} Debug mode for the ${environment} environment is active. Click the "DEBUG" button in the opened browser window to start debugging.`, + ); + logger.verbose('Press CTRL+C to stop debugging...'); + }); +}; + +async function launchDebugMode( + environment: KarmaEnvironment, + config: KarmaConfigOptions, +): Promise { + return new Promise((resolve) => { + const server = createKarmaServer(config, (exitCode: number) => { + resolve({ success: exitCode === 0, environment, duration: 0 }); + }); + + setupDebugModeEvents(environment, server); + setupSignalHandlers(server); + + logger.verbose(`\n${STATUS_ICONS.START} Starting Karma server in debug mode...`); + server.start(); + }); +} + +async function handleDebugMode( + options: KarmaMultiEnvExecutorSchema, + projectRoot: string, + environments: KarmaEnvironment[], +): Promise { + try { + const baseConfig = await loadKarmaConfig(projectRoot, options.karmaConfig); + const envConfig = ENVIRONMENT_CONFIGS[environments[0]]; + const shimPath = path.join(projectRoot, envConfig.shimPath); + const config = createTestConfig(baseConfig, shimPath, options); + + const result = await launchDebugMode(environments[0], config); + + return { success: result.success }; + } catch (error) { + logger.error(`\n${STATUS_ICONS.ERROR} Test execution failed: ${error.message}`); + return { success: false }; + } +} + +async function handleSingleExecution( + options: KarmaMultiEnvExecutorSchema, + projectRoot: string, + environments: KarmaEnvironment[], +): Promise { + try { + const baseConfig = await loadKarmaConfig(projectRoot, options.karmaConfig); + const plan = planTestExecution(options, environments); + const results: TestResult[] = []; + + logExecutionStart(plan, options); + + for (const environment of plan.executionOrder) { + const envConfig = ENVIRONMENT_CONFIGS[environment]; + const shimPath = path.join(projectRoot, envConfig.shimPath); + + logEnvironmentStart(environment); + + try { + const config = createTestConfig(baseConfig, shimPath, options); + const result = await executeSingleRun(environment, config, plan.timeout); + results.push(result); + + if (shouldStopExecution(result, false)) { + logger.error(`\n[${environment.toUpperCase()}] Stopping execution after test failure`); + break; + } + } catch (error) { + const errorHandler = createErrorHandler(environment); + results.push(errorHandler.createErrorResult(error, 0)); + logger.error(`\n[${environment.toUpperCase()}] Stopping execution after error`); + break; + } + } + + const summary = summarizeTestResults(results); + logTestResults(summary, plan, options); + + return { success: summary.summary.failed === 0 }; + } catch (error) { + logger.error(`\n${STATUS_ICONS.ERROR} Test execution failed: ${error.message}`); + return { success: false }; + } +} + +interface ResolvedKarmaMultiEnv { + environments: KarmaEnvironment[]; + executionMode: ExecutionMode; +} + +export default createExecutor({ + name: 'KarmaMultiEnv', + resolve: (options) => { + const environments = options.environments || DEFAULT_ENVIRONMENTS; + const executionMode = getExecutionMode(options); + return { environments, executionMode }; + }, + run: async (resolved, options, { projectRoot }) => { + if (resolved.executionMode === 'watch') { + const outcome = await handleWatchMode(options, projectRoot); + if (!outcome.success) { + throw new Error('Karma watch mode reported failure'); + } + return; + } + + if (resolved.executionMode === 'debug') { + if (options.environments && options.environments.length > 1) { + logger.error( + `Debug mode supports only a single environment, please specify one environment. Current environments: ${options.environments.join(', ')}`, + ); + throw new Error('Debug mode requires exactly one environment'); + } + + const outcome = await handleDebugMode(options, projectRoot, resolved.environments); + if (!outcome.success) { + throw new Error('Karma debug mode reported failure'); + } + return; + } + + const outcome = await handleSingleExecution(options, projectRoot, resolved.environments); + if (!outcome.success) { + throw new Error('Karma tests reported failure'); + } + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/localization/executor.ts b/packages/nx-infra-plugin/src/executors/localization/executor.ts index 787aea32c342..96f90d7427d2 100644 --- a/packages/nx-infra-plugin/src/executors/localization/executor.ts +++ b/packages/nx-infra-plugin/src/executors/localization/executor.ts @@ -1,443 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import * as fs from 'fs'; -import { createRequire } from 'module'; -import _ from 'lodash'; -import { LocalizationExecutorSchema } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; -import { readFileText, writeFileText, readJson } from '../../utils/file-operations'; - -interface CldrInstance { - supplemental: { - weekData: { - firstDay: () => string; - }; - }; -} - -interface CldrConstructor { - load: (...data: unknown[]) => void; - new (locale: string): CldrInstance; -} - -interface CldrModuleDefinition { - data: unknown; - filename: string; - exportName?: string; - destination: string; -} - -interface CldrDependencies { - Cldr: CldrConstructor; - locales: string[]; - weekData: unknown; - likelySubtags: unknown; - parentLocales: Record; - globalizeEnCldr: unknown; - globalizeSupplementalCldr: unknown; -} - -const DEFAULT_MESSAGES_DIR = './js/localization/messages'; -const DEFAULT_MESSAGE_TEMPLATE = './build/gulp/localization-template.jst'; -const DEFAULT_MESSAGE_OUTPUT_DIR = './artifacts/js/localization'; -const DEFAULT_GENERATED_TEMPLATE = './build/gulp/generated_js.jst'; -const DEFAULT_CLDR_DATA_OUTPUT_DIR = './js/__internal/core/localization/cldr-data'; -const DEFAULT_DEFAULT_MESSAGES_OUTPUT_DIR = './js/__internal/core/localization'; - -const PARENT_LOCALE_SEPARATOR = '-'; - -const DAY_INDEXES = { - sun: 0, - mon: 1, - tue: 2, - wed: 3, - thu: 4, - fri: 5, - sat: 6, -} as const; - -const DEFAULT_DAY_OF_WEEK_INDEX = DAY_INDEXES.sun; - -const ERROR_MESSAGES = { - MESSAGES_DIR_NOT_FOUND: (dir: string) => `Messages directory not found: ${dir}`, - MESSAGE_TEMPLATE_NOT_FOUND: (path: string) => `Message template not found: ${path}`, - GENERATED_TEMPLATE_NOT_FOUND: (path: string) => `Generated template not found: ${path}`, - CLDR_DEPENDENCIES_LOAD_FAILED: (error: string) => - `Failed to load CLDR dependencies. Ensure cldr-core, cldrjs, and devextreme-cldr-data ` - + `are installed in the project: ${error}`, -} as const; - -const CLDR_MODULE_CONFIGS = { - DEFAULT_MESSAGES: { - filename: 'default_messages.ts', - exportName: 'defaultMessages', - }, - PARENT_LOCALES: { - filename: 'parent_locales.ts', - }, - FIRST_DAY_OF_WEEK: { - filename: 'first_day_of_week_data.ts', - }, - ACCOUNTING_FORMATS: { - filename: 'accounting_formats.ts', - }, - EN_CLDR: { - filename: 'en.ts', - exportName: 'enCldr', - }, - SUPPLEMENTAL: { - filename: 'supplemental.ts', - exportName: 'supplementalCldr', - }, -} as const; - -function loadCldrDependencies(projectRequire: NodeRequire): CldrDependencies { - try { - return { - Cldr: projectRequire('cldrjs') as CldrConstructor, - locales: projectRequire('cldr-core/availableLocales.json').availableLocales.full, - weekData: projectRequire('cldr-core/supplemental/weekData.json'), - likelySubtags: projectRequire('cldr-core/supplemental/likelySubtags.json'), - parentLocales: projectRequire('cldr-core/supplemental/parentLocales.json').supplemental - .parentLocales.parentLocale, - globalizeEnCldr: projectRequire('devextreme-cldr-data/en.json'), - globalizeSupplementalCldr: projectRequire('devextreme-cldr-data/supplemental.json'), - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(ERROR_MESSAGES.CLDR_DEPENDENCIES_LOAD_FAILED(message)); - } -} - -function validateInputPaths( - messagesDir: string, - messageTemplate: string, - generatedTemplate: string, - skipMessageGeneration: boolean, - skipCldrGeneration: boolean, -): void { - if (!fs.existsSync(messagesDir)) { - throw new Error(ERROR_MESSAGES.MESSAGES_DIR_NOT_FOUND(messagesDir)); - } - if (!skipMessageGeneration && !fs.existsSync(messageTemplate)) { - throw new Error(ERROR_MESSAGES.MESSAGE_TEMPLATE_NOT_FOUND(messageTemplate)); - } - if (!skipCldrGeneration && !fs.existsSync(generatedTemplate)) { - throw new Error(ERROR_MESSAGES.GENERATED_TEMPLATE_NOT_FOUND(generatedTemplate)); - } -} - -function shouldIncludeLocaleInFirstDayData( - firstDayIndex: number, - parentLocale: string | false, - getFirstIndex: (locale: string) => number, -): boolean { - if (firstDayIndex === DEFAULT_DAY_OF_WEEK_INDEX) { - return false; - } - if (!parentLocale) { - return true; - } - return firstDayIndex !== getFirstIndex(parentLocale); -} - -function createCldrModuleDefinitions( - enMessages: unknown, - deps: CldrDependencies, - firstDayData: Record, - accountingFormats: Record, - cldrDataOutputDir: string, - defaultMessagesOutputDir: string, -): CldrModuleDefinition[] { - return [ - { - data: enMessages, - ...CLDR_MODULE_CONFIGS.DEFAULT_MESSAGES, - destination: defaultMessagesOutputDir, - }, - { - data: deps.parentLocales, - ...CLDR_MODULE_CONFIGS.PARENT_LOCALES, - destination: cldrDataOutputDir, - }, - { - data: firstDayData, - ...CLDR_MODULE_CONFIGS.FIRST_DAY_OF_WEEK, - destination: cldrDataOutputDir, - }, - { - data: accountingFormats, - ...CLDR_MODULE_CONFIGS.ACCOUNTING_FORMATS, - destination: cldrDataOutputDir, - }, - { - data: deps.globalizeEnCldr, - ...CLDR_MODULE_CONFIGS.EN_CLDR, - destination: cldrDataOutputDir, - }, - { - data: deps.globalizeSupplementalCldr, - ...CLDR_MODULE_CONFIGS.SUPPLEMENTAL, - destination: cldrDataOutputDir, - }, - ]; -} - -function getLocales(directory: string): string[] { - return fs - .readdirSync(directory) - .filter((file) => file.endsWith('.json')) - .map((file) => file.replace('.json', '')); -} - -function serializeObject(obj: unknown, shift = false): string { - const tab = ' '; - let result = JSON.stringify(obj, null, tab); - - if (shift) { - result = result.replace(/(\n)/g, '$1' + tab); - } - - return result; -} - -function getParentLocale(parentLocales: Record, locale: string): string | false { - const parentLocale = parentLocales[locale]; - - if (parentLocale) { - return parentLocale !== 'root' && parentLocale; - } - - const lastSeparatorIndex = locale.lastIndexOf(PARENT_LOCALE_SEPARATOR); - return lastSeparatorIndex > 0 ? locale.substring(0, lastSeparatorIndex) : false; -} - -async function generateMessageFiles( - messagesDir: string, - templatePath: string, - outputDir: string, -): Promise { - const templateContent = await readFileText(templatePath); - const compiled = _.template(templateContent); - - const locales = getLocales(messagesDir); - - logger.verbose(`Processing ${locales.length} locales...`); - - await Promise.all( - locales.map(async (locale) => { - const messagesPath = path.join(messagesDir, `${locale}.json`); - const messages = await readJson(messagesPath); - const json = serializeObject(messages, true); - - const content = compiled({ json }); - - const outputPath = path.join(outputDir, `dx.messages.${locale}.js`); - await writeFileText(outputPath, content); - }), - ); -} - -async function generateCldrModules( - projectRoot: string, - messagesDir: string, - templatePath: string, - cldrDataOutputDir: string, - defaultMessagesOutputDir: string, - lintGeneratedFiles: boolean, -): Promise { - const templateContent = await readFileText(templatePath); - const compiled = _.template(templateContent); - - const projectRequire = createRequire(path.join(projectRoot, 'package.json')); - const deps = loadCldrDependencies(projectRequire); - const enMessages = await readJson(path.join(messagesDir, 'en.json')); - - const firstDayData = computeFirstDayOfWeekData(deps); - const accountingFormats = computeAccountingFormats(deps.locales, projectRequire); - - const modules = createCldrModuleDefinitions( - enMessages, - deps, - firstDayData, - accountingFormats, - cldrDataOutputDir, - defaultMessagesOutputDir, - ); - - await Promise.all( - modules.map(async (module) => { - const json = serializeObject(module.data); - const content = compiled({ - exportName: module.exportName, - json, - }); - const outputPath = path.join(module.destination, module.filename); - await writeFileText(outputPath, content); - }), - ); - - if (lintGeneratedFiles) { - await lintFiles(cldrDataOutputDir, defaultMessagesOutputDir, projectRoot, projectRequire); - } -} - -function computeFirstDayOfWeekData(deps: CldrDependencies): Record { - const { Cldr, locales, weekData, likelySubtags, parentLocales } = deps; - const result: Record = {}; - - Cldr.load(weekData, likelySubtags); - - const getFirstIndex = (locale: string): number => { - const firstDay = new Cldr(locale).supplemental.weekData.firstDay(); - return DAY_INDEXES[firstDay as keyof typeof DAY_INDEXES]; - }; - - for (const locale of locales) { - const firstDayIndex = getFirstIndex(locale); - const parentLocale = getParentLocale(parentLocales, locale); - - if (shouldIncludeLocaleInFirstDayData(firstDayIndex, parentLocale, getFirstIndex)) { - result[locale] = firstDayIndex; - } - } - - return result; -} - -function computeAccountingFormats( - locales: string[], - projectRequire: NodeRequire, -): Record { - const result: Record = {}; - - for (const locale of locales) { - try { - const numbersData = projectRequire(`cldr-numbers-full/main/${locale}/numbers.json`); - const accounting = - numbersData.main[locale].numbers['currencyFormats-numberSystem-latn'].accounting; - result[locale] = accounting; - } catch { - // Skip locales without numbers data - } - } - - return result; -} - -async function lintFiles( - cldrDataOutputDir: string, - defaultMessagesOutputDir: string, - projectRoot: string, - projectRequire: NodeRequire, -): Promise { - try { - const { ESLint } = projectRequire('eslint'); - - const eslint = new ESLint({ - fix: true, - cwd: projectRoot, - overrideConfig: { - linterOptions: { - reportUnusedDisableDirectives: 'off', - }, - }, - }); - - const filesToLint = [ - path.join(cldrDataOutputDir, '*.ts'), - path.join(defaultMessagesOutputDir, 'default_messages.ts'), - ]; - - const results = await eslint.lintFiles(filesToLint); - - await ESLint.outputFixes(results); - - const errorCount = results.reduce( - (sum: number, result: { errorCount: number }) => sum + result.errorCount, - 0, - ); - if (errorCount > 0) { - logger.warn(`ESLint found ${errorCount} errors in generated files`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.warn(`ESLint not available, skipping linting of generated files: ${errorMessage}`); - } -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const absoluteProjectRoot = resolveProjectPath(context); - - const messagesDir = path.join(absoluteProjectRoot, options.messagesDir || DEFAULT_MESSAGES_DIR); - const messageTemplate = path.join( - absoluteProjectRoot, - options.messageTemplate || DEFAULT_MESSAGE_TEMPLATE, - ); - const messageOutputDir = path.join( - absoluteProjectRoot, - options.messageOutputDir || DEFAULT_MESSAGE_OUTPUT_DIR, - ); - const generatedTemplate = path.join( - absoluteProjectRoot, - options.generatedTemplate || DEFAULT_GENERATED_TEMPLATE, - ); - const cldrDataOutputDir = path.join( - absoluteProjectRoot, - options.cldrDataOutputDir || DEFAULT_CLDR_DATA_OUTPUT_DIR, - ); - const defaultMessagesOutputDir = path.join( - absoluteProjectRoot, - options.defaultMessagesOutputDir || DEFAULT_DEFAULT_MESSAGES_OUTPUT_DIR, - ); - - const skipCldrGeneration = options.skipCldrGeneration ?? false; - const skipMessageGeneration = options.skipMessageGeneration ?? false; - const lintGeneratedFiles = options.lintGeneratedFiles ?? true; - - try { - validateInputPaths( - messagesDir, - messageTemplate, - generatedTemplate, - skipMessageGeneration, - skipCldrGeneration, - ); - - if (!skipMessageGeneration) { - fs.mkdirSync(messageOutputDir, { recursive: true }); - } - if (!skipCldrGeneration) { - fs.mkdirSync(cldrDataOutputDir, { recursive: true }); - fs.mkdirSync(defaultMessagesOutputDir, { recursive: true }); - } - - if (!skipMessageGeneration) { - logger.verbose('Generating localization message files...'); - await generateMessageFiles(messagesDir, messageTemplate, messageOutputDir); - logger.verbose(`Message files generated in ${messageOutputDir}`); - } - - if (!skipCldrGeneration) { - logger.verbose('Generating CLDR TypeScript modules...'); - await generateCldrModules( - absoluteProjectRoot, - messagesDir, - generatedTemplate, - cldrDataOutputDir, - defaultMessagesOutputDir, - lintGeneratedFiles, - ); - logger.verbose(`CLDR modules generated in ${cldrDataOutputDir}`); - } - - logger.verbose('Localization generation completed successfully'); - return { success: true }; - } catch (error) { - logError('Localization executor failed', error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './localization.impl'; diff --git a/packages/nx-infra-plugin/src/executors/localization/localization.impl.ts b/packages/nx-infra-plugin/src/executors/localization/localization.impl.ts new file mode 100644 index 000000000000..6847d22a1bc6 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/localization/localization.impl.ts @@ -0,0 +1,464 @@ +import { logger } from '@nx/devkit'; +import * as path from 'path'; +import * as fs from 'fs'; +import { createRequire } from 'module'; +import _ from 'lodash'; +import { createExecutor } from '../../utils/create-executor'; +import { readFileText, writeFileText, readJson } from '../../utils/file-operations'; +import { LocalizationExecutorSchema } from './schema'; + +interface CldrInstance { + supplemental: { + weekData: { + firstDay: () => string; + }; + }; +} + +interface CldrConstructor { + load: (...data: unknown[]) => void; + new (locale: string): CldrInstance; +} + +interface CldrModuleDefinition { + data: unknown; + filename: string; + exportName?: string; + destination: string; +} + +interface CldrDependencies { + Cldr: CldrConstructor; + locales: string[]; + weekData: unknown; + likelySubtags: unknown; + parentLocales: Record; + globalizeEnCldr: unknown; + globalizeSupplementalCldr: unknown; +} + +const DEFAULT_MESSAGES_DIR = './js/localization/messages'; +const DEFAULT_MESSAGE_TEMPLATE = './build/gulp/localization-template.jst'; +const DEFAULT_MESSAGE_OUTPUT_DIR = './artifacts/js/localization'; +const DEFAULT_GENERATED_TEMPLATE = './build/gulp/generated_js.jst'; +const DEFAULT_CLDR_DATA_OUTPUT_DIR = './js/__internal/core/localization/cldr-data'; +const DEFAULT_DEFAULT_MESSAGES_OUTPUT_DIR = './js/__internal/core/localization'; + +const PARENT_LOCALE_SEPARATOR = '-'; + +const DAY_INDEXES = { + sun: 0, + mon: 1, + tue: 2, + wed: 3, + thu: 4, + fri: 5, + sat: 6, +} as const; + +const DEFAULT_DAY_OF_WEEK_INDEX = DAY_INDEXES.sun; + +const ERROR_MESSAGES = { + MESSAGES_DIR_NOT_FOUND: (directory: string) => `Messages directory not found: ${directory}`, + MESSAGE_TEMPLATE_NOT_FOUND: (templatePath: string) => + `Message template not found: ${templatePath}`, + GENERATED_TEMPLATE_NOT_FOUND: (templatePath: string) => + `Generated template not found: ${templatePath}`, + CLDR_DEPENDENCIES_LOAD_FAILED: (error: string) => + `Failed to load CLDR dependencies. Ensure cldr-core, cldrjs, and devextreme-cldr-data ` + + `are installed in the project: ${error}`, +} as const; + +const CLDR_MODULE_CONFIGS = { + DEFAULT_MESSAGES: { + filename: 'default_messages.ts', + exportName: 'defaultMessages', + }, + PARENT_LOCALES: { + filename: 'parent_locales.ts', + }, + FIRST_DAY_OF_WEEK: { + filename: 'first_day_of_week_data.ts', + }, + ACCOUNTING_FORMATS: { + filename: 'accounting_formats.ts', + }, + EN_CLDR: { + filename: 'en.ts', + exportName: 'enCldr', + }, + SUPPLEMENTAL: { + filename: 'supplemental.ts', + exportName: 'supplementalCldr', + }, +} as const; + +function loadCldrDependencies(projectRequire: NodeRequire): CldrDependencies { + try { + return { + Cldr: projectRequire('cldrjs') as CldrConstructor, + locales: projectRequire('cldr-core/availableLocales.json').availableLocales.full, + weekData: projectRequire('cldr-core/supplemental/weekData.json'), + likelySubtags: projectRequire('cldr-core/supplemental/likelySubtags.json'), + parentLocales: projectRequire('cldr-core/supplemental/parentLocales.json').supplemental + .parentLocales.parentLocale, + globalizeEnCldr: projectRequire('devextreme-cldr-data/en.json'), + globalizeSupplementalCldr: projectRequire('devextreme-cldr-data/supplemental.json'), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(ERROR_MESSAGES.CLDR_DEPENDENCIES_LOAD_FAILED(message)); + } +} + +function validateInputPaths( + messagesDir: string, + messageTemplate: string, + generatedTemplate: string, + skipMessageGeneration: boolean, + skipCldrGeneration: boolean, +): void { + if (!fs.existsSync(messagesDir)) { + throw new Error(ERROR_MESSAGES.MESSAGES_DIR_NOT_FOUND(messagesDir)); + } + if (!skipMessageGeneration && !fs.existsSync(messageTemplate)) { + throw new Error(ERROR_MESSAGES.MESSAGE_TEMPLATE_NOT_FOUND(messageTemplate)); + } + if (!skipCldrGeneration && !fs.existsSync(generatedTemplate)) { + throw new Error(ERROR_MESSAGES.GENERATED_TEMPLATE_NOT_FOUND(generatedTemplate)); + } +} + +function shouldIncludeLocaleInFirstDayData( + firstDayIndex: number, + parentLocale: string | false, + getFirstIndex: (locale: string) => number, +): boolean { + if (firstDayIndex === DEFAULT_DAY_OF_WEEK_INDEX) { + return false; + } + if (!parentLocale) { + return true; + } + return firstDayIndex !== getFirstIndex(parentLocale); +} + +function createCldrModuleDefinitions( + enMessages: unknown, + dependencies: CldrDependencies, + firstDayData: Record, + accountingFormats: Record, + cldrDataOutputDir: string, + defaultMessagesOutputDir: string, +): CldrModuleDefinition[] { + return [ + { + data: enMessages, + ...CLDR_MODULE_CONFIGS.DEFAULT_MESSAGES, + destination: defaultMessagesOutputDir, + }, + { + data: dependencies.parentLocales, + ...CLDR_MODULE_CONFIGS.PARENT_LOCALES, + destination: cldrDataOutputDir, + }, + { + data: firstDayData, + ...CLDR_MODULE_CONFIGS.FIRST_DAY_OF_WEEK, + destination: cldrDataOutputDir, + }, + { + data: accountingFormats, + ...CLDR_MODULE_CONFIGS.ACCOUNTING_FORMATS, + destination: cldrDataOutputDir, + }, + { + data: dependencies.globalizeEnCldr, + ...CLDR_MODULE_CONFIGS.EN_CLDR, + destination: cldrDataOutputDir, + }, + { + data: dependencies.globalizeSupplementalCldr, + ...CLDR_MODULE_CONFIGS.SUPPLEMENTAL, + destination: cldrDataOutputDir, + }, + ]; +} + +function getLocales(directory: string): string[] { + return fs + .readdirSync(directory) + .filter((file) => file.endsWith('.json')) + .map((file) => file.replace('.json', '')); +} + +function serializeObject(value: unknown, shift = false): string { + const tab = ' '; + let result = JSON.stringify(value, null, tab); + + if (shift) { + result = result.replace(/(\n)/g, '$1' + tab); + } + + return result; +} + +function getParentLocale(parentLocales: Record, locale: string): string | false { + const parentLocale = parentLocales[locale]; + + if (parentLocale) { + return parentLocale !== 'root' && parentLocale; + } + + const lastSeparatorIndex = locale.lastIndexOf(PARENT_LOCALE_SEPARATOR); + return lastSeparatorIndex > 0 ? locale.substring(0, lastSeparatorIndex) : false; +} + +async function generateMessageFiles( + messagesDir: string, + templatePath: string, + outputDir: string, +): Promise { + const templateContent = await readFileText(templatePath); + const compiled = _.template(templateContent); + + const locales = getLocales(messagesDir); + + logger.verbose(`Processing ${locales.length} locales...`); + + await Promise.all( + locales.map(async (locale) => { + const messagesPath = path.join(messagesDir, `${locale}.json`); + const messages = await readJson(messagesPath); + const json = serializeObject(messages, true); + + const content = compiled({ json }); + + const outputPath = path.join(outputDir, `dx.messages.${locale}.js`); + await writeFileText(outputPath, content); + }), + ); +} + +function computeFirstDayOfWeekData(dependencies: CldrDependencies): Record { + const { Cldr, locales, weekData, likelySubtags, parentLocales } = dependencies; + const result: Record = {}; + + Cldr.load(weekData, likelySubtags); + + const getFirstIndex = (locale: string): number => { + const firstDay = new Cldr(locale).supplemental.weekData.firstDay(); + return DAY_INDEXES[firstDay as keyof typeof DAY_INDEXES]; + }; + + for (const locale of locales) { + const firstDayIndex = getFirstIndex(locale); + const parentLocale = getParentLocale(parentLocales, locale); + + if (shouldIncludeLocaleInFirstDayData(firstDayIndex, parentLocale, getFirstIndex)) { + result[locale] = firstDayIndex; + } + } + + return result; +} + +function computeAccountingFormats( + locales: string[], + projectRequire: NodeRequire, +): Record { + const result: Record = {}; + + for (const locale of locales) { + try { + const numbersData = projectRequire(`cldr-numbers-full/main/${locale}/numbers.json`); + const accounting = + numbersData.main[locale].numbers['currencyFormats-numberSystem-latn'].accounting; + result[locale] = accounting; + } catch { + void 0; + } + } + + return result; +} + +async function lintFiles( + cldrDataOutputDir: string, + defaultMessagesOutputDir: string, + projectRoot: string, + projectRequire: NodeRequire, +): Promise { + try { + const { ESLint } = projectRequire('eslint'); + + const eslint = new ESLint({ + fix: true, + cwd: projectRoot, + overrideConfig: { + linterOptions: { + reportUnusedDisableDirectives: 'off', + }, + }, + }); + + const filesToLint = [ + path.join(cldrDataOutputDir, '*.ts'), + path.join(defaultMessagesOutputDir, 'default_messages.ts'), + ]; + + const results = await eslint.lintFiles(filesToLint); + + await ESLint.outputFixes(results); + + const errorCount = results.reduce( + (sum: number, result: { errorCount: number }) => sum + result.errorCount, + 0, + ); + if (errorCount > 0) { + logger.warn(`ESLint found ${errorCount} errors in generated files`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn(`ESLint not available, skipping linting of generated files: ${errorMessage}`); + } +} + +async function generateCldrModules( + projectRoot: string, + messagesDir: string, + templatePath: string, + cldrDataOutputDir: string, + defaultMessagesOutputDir: string, + lintGeneratedFiles: boolean, +): Promise { + const templateContent = await readFileText(templatePath); + const compiled = _.template(templateContent); + + const projectRequire = createRequire(path.join(projectRoot, 'package.json')); + const dependencies = loadCldrDependencies(projectRequire); + const enMessages = await readJson(path.join(messagesDir, 'en.json')); + + const firstDayData = computeFirstDayOfWeekData(dependencies); + const accountingFormats = computeAccountingFormats(dependencies.locales, projectRequire); + + const modules = createCldrModuleDefinitions( + enMessages, + dependencies, + firstDayData, + accountingFormats, + cldrDataOutputDir, + defaultMessagesOutputDir, + ); + + await Promise.all( + modules.map(async (moduleDefinition) => { + const json = serializeObject(moduleDefinition.data); + const content = compiled({ + exportName: moduleDefinition.exportName, + json, + }); + const outputPath = path.join(moduleDefinition.destination, moduleDefinition.filename); + await writeFileText(outputPath, content); + }), + ); + + if (lintGeneratedFiles) { + await lintFiles(cldrDataOutputDir, defaultMessagesOutputDir, projectRoot, projectRequire); + } +} + +interface ResolvedLocalization { + projectRoot: string; + messagesDir: string; + messageTemplate: string; + messageOutputDir: string; + generatedTemplate: string; + cldrDataOutputDir: string; + defaultMessagesOutputDir: string; + skipCldrGeneration: boolean; + skipMessageGeneration: boolean; + lintGeneratedFiles: boolean; +} + +export default createExecutor({ + name: 'Localization', + resolve: (options, { projectRoot }) => { + const messagesDir = path.join(projectRoot, options.messagesDir || DEFAULT_MESSAGES_DIR); + const messageTemplate = path.join( + projectRoot, + options.messageTemplate || DEFAULT_MESSAGE_TEMPLATE, + ); + const messageOutputDir = path.join( + projectRoot, + options.messageOutputDir || DEFAULT_MESSAGE_OUTPUT_DIR, + ); + const generatedTemplate = path.join( + projectRoot, + options.generatedTemplate || DEFAULT_GENERATED_TEMPLATE, + ); + const cldrDataOutputDir = path.join( + projectRoot, + options.cldrDataOutputDir || DEFAULT_CLDR_DATA_OUTPUT_DIR, + ); + const defaultMessagesOutputDir = path.join( + projectRoot, + options.defaultMessagesOutputDir || DEFAULT_DEFAULT_MESSAGES_OUTPUT_DIR, + ); + + return { + projectRoot, + messagesDir, + messageTemplate, + messageOutputDir, + generatedTemplate, + cldrDataOutputDir, + defaultMessagesOutputDir, + skipCldrGeneration: options.skipCldrGeneration ?? false, + skipMessageGeneration: options.skipMessageGeneration ?? false, + lintGeneratedFiles: options.lintGeneratedFiles ?? true, + }; + }, + run: async (resolved) => { + validateInputPaths( + resolved.messagesDir, + resolved.messageTemplate, + resolved.generatedTemplate, + resolved.skipMessageGeneration, + resolved.skipCldrGeneration, + ); + + if (!resolved.skipMessageGeneration) { + fs.mkdirSync(resolved.messageOutputDir, { recursive: true }); + } + if (!resolved.skipCldrGeneration) { + fs.mkdirSync(resolved.cldrDataOutputDir, { recursive: true }); + fs.mkdirSync(resolved.defaultMessagesOutputDir, { recursive: true }); + } + + if (!resolved.skipMessageGeneration) { + logger.verbose('Generating localization message files...'); + await generateMessageFiles( + resolved.messagesDir, + resolved.messageTemplate, + resolved.messageOutputDir, + ); + logger.verbose(`Message files generated in ${resolved.messageOutputDir}`); + } + + if (!resolved.skipCldrGeneration) { + logger.verbose('Generating CLDR TypeScript modules...'); + await generateCldrModules( + resolved.projectRoot, + resolved.messagesDir, + resolved.generatedTemplate, + resolved.cldrDataOutputDir, + resolved.defaultMessagesOutputDir, + resolved.lintGeneratedFiles, + ); + logger.verbose(`CLDR modules generated in ${resolved.cldrDataOutputDir}`); + } + + logger.verbose('Localization generation completed successfully'); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts index b6c9e511f3ca..7568816ba6fc 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts @@ -59,7 +59,7 @@ describe('NpmAssembleExecutor E2E', () => { cleanupTempDir(tempDir); }); - it('should copy transpiled JS sources with license filter and apply star-license banners', async () => { + it('should copy transpiled JS sources and exclude bundles + internal license validation', async () => { const transpiledDir = path.join(projectDir, 'artifacts', 'transpiled-esm-npm'); fs.mkdirSync(path.join(transpiledDir, 'esm'), { recursive: true }); fs.mkdirSync(path.join(transpiledDir, 'bundles'), { recursive: true }); @@ -76,52 +76,31 @@ describe('NpmAssembleExecutor E2E', () => { expect(result.success).toBe(true); const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); - - const buttonContent = await readFileText(path.join(outDir, 'esm', 'button.js')); - expect(buttonContent).toMatch(/^\/\*\*/); - expect(buttonContent).toContain('esm/button.js'); - + expect(fs.existsSync(path.join(outDir, 'esm', 'button.js'))).toBe(true); expect(fs.existsSync(path.join(outDir, 'bundles', 'dx.all.js'))).toBe(false); expect( fs.existsSync(path.join(outDir, 'esm', 'license', 'license_validation_internal.js')), ).toBe(false); }); - it('should copy license dir and npm-bin scripts with LF line endings', async () => { - await writeFileText( - path.join(projectDir, 'license', 'LICENSE.txt'), - 'DevExtreme License\r\nCopyright 2024\r\n', - ); - await writeFileText( - path.join(projectDir, 'build', 'npm-bin', 'install.js'), - 'var a = 1;\r\nvar b = 2;\r\n', - ); + it('should copy license dir and npm-bin scripts to expected destinations', async () => { + await writeFileText(path.join(projectDir, 'license', 'LICENSE.txt'), 'DevExtreme License\n'); + await writeFileText(path.join(projectDir, 'build', 'npm-bin', 'install.js'), 'var a = 1;\n'); const result = await executor(OPTIONS, context); expect(result.success).toBe(true); const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); - - const licenseContent = await readFileText(path.join(outDir, 'license', 'LICENSE.txt')); - expect(licenseContent).not.toContain('\r\n'); - expect(licenseContent).toContain('\n'); - - const binContent = await readFileText(path.join(outDir, 'bin', 'install.js')); - expect(binContent).not.toContain('\r\n'); - expect(binContent).toContain('\n'); + expect(fs.existsSync(path.join(outDir, 'license', 'LICENSE.txt'))).toBe(true); + expect(fs.existsSync(path.join(outDir, 'bin', 'install.js'))).toBe(true); }); it('should copy dist files into outputDir/dist with the gulp-equivalent excludes', async () => { const artifactsDir = path.join(projectDir, 'artifacts'); fs.mkdirSync(path.join(artifactsDir, 'js'), { recursive: true }); - fs.mkdirSync(path.join(artifactsDir, 'css'), { recursive: true }); - fs.mkdirSync(path.join(artifactsDir, 'ts'), { recursive: true }); await writeFileText(path.join(artifactsDir, 'js', 'dx.all.js'), 'var dx = {};'); await writeFileText(path.join(artifactsDir, 'js', 'jquery.js'), 'var $ = {};'); - await writeFileText(path.join(artifactsDir, 'css', 'dx.light.css'), '.dx { }'); - await writeFileText(path.join(artifactsDir, 'css', 'dx-diagram.css'), '.diagram { }'); - await writeFileText(path.join(artifactsDir, 'ts', 'dx.all.d.ts'), 'export {}'); const result = await executor(OPTIONS, context); expect(result.success).toBe(true); @@ -130,8 +109,5 @@ describe('NpmAssembleExecutor E2E', () => { expect(fs.existsSync(path.join(distDir, 'js', 'dx.all.js'))).toBe(true); expect(fs.existsSync(path.join(distDir, 'js', 'jquery.js'))).toBe(false); - expect(fs.existsSync(path.join(distDir, 'css', 'dx.light.css'))).toBe(true); - expect(fs.existsSync(path.join(distDir, 'css', 'dx-diagram.css'))).toBe(false); - expect(fs.existsSync(path.join(distDir, 'ts', 'dx.all.d.ts'))).toBe(true); }); }); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts index fe48a6bc6961..301e8a971ca6 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts @@ -1,192 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import * as fs from 'fs/promises'; -import { glob } from 'glob'; -import { NpmAssembleExecutorSchema } from './schema'; -import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; -import { - readFileText, - writeFileText, - copyFile, - readJson, - normalizeEol, - ensureTrailingNewline, - ensureDir, -} from '../../utils/file-operations'; -import { copyDirectory } from '../../utils/copy-directory'; -import { buildLicenseBannerRenderer, applyLicenseBannerToFile } from '../../utils/license-banner'; -import { DEFAULT_LICENSE_TEMPLATE_EULA, DEFAULT_EULA_URL } from '../add-license-headers/defaults'; -import type { PackageJson } from '../../utils/types'; - -const SRC_JS_EXCLUDES = [ - 'bundles/*.js', - 'cjs/bundles/**/*', - 'esm/bundles/**/*', - 'bundles/modules/parts/*.js', - 'viz/vector_map.utils/*.js', - 'viz/docs/*.js', - '**/license/license_validation_internal.js', -]; - -const DIST_EXCLUDES = [ - 'transpiled**/**/*', - 'npm/**/*.*', - 'ts/jquery*', - 'ts/knockout*', - 'ts/globalize*', - 'ts/cldr*', - 'css/dx-diagram.*', - 'css/dx-gantt.*', - 'js/knockout*', - 'js/cldr/*.*', - 'js/cldr*', - 'js/globalize/*.*', - 'js/globalize*', - 'js/dx-exceljs-fork*', - 'js/file-saver*', - 'js/jquery*', - 'js/jspdf*', - 'js/jspdf-autotable*', - 'js/jszip*', - 'js/dx.custom*', - 'js/dx.viz*', - 'js/dx.web*', - 'js/dx-diagram*', - 'js/dx-gantt*', - 'js/dx-quill*', -]; - -async function copySourceJs(transpiledDir: string, outputDir: string): Promise { - await copyDirectory(transpiledDir, outputDir, { - include: ['**/*.js'], - exclude: SRC_JS_EXCLUDES, - }); -} - -async function copyEsmPackageJsonFiles(transpiledDir: string, outputDir: string): Promise { - await copyDirectory(transpiledDir, outputDir, { - include: ['**/*.json'], - exclude: ['viz/vector_map.utils/**'], - }); -} - -async function copyJsSrcJsonFiles(jsSrcDir: string, outputDir: string): Promise { - await copyDirectory(jsSrcDir, outputDir, { - include: ['**/*.json'], - exclude: ['viz/vector_map.utils/**'], - }); -} - -async function copyAndNormalizeFiles( - srcDir: string, - outDir: string, - pattern: string, -): Promise { - const cwd = toPosixPath(srcDir); - const relPaths = await glob(pattern, { cwd, nodir: true }); - await Promise.all( - relPaths.map(async (rel) => { - const dest = path.join(outDir, rel); - await ensureDir(path.dirname(dest)); - await fs.copyFile(path.join(srcDir, rel), dest); - const content = await readFileText(dest); - await writeFileText(dest, ensureTrailingNewline(normalizeEol(content))); - }), - ); -} - -async function copyLicenseFiles(licenseSrcDir: string, outputDir: string): Promise { - await copyAndNormalizeFiles(licenseSrcDir, path.join(outputDir, 'license'), '**/*'); -} - -async function copyNpmBinFiles(npmBinDir: string, outputDir: string): Promise { - await copyAndNormalizeFiles(npmBinDir, path.join(outputDir, 'bin'), '*.js'); -} - -async function copyDistFiles(artifactsDir: string, outputDir: string): Promise { - await copyDirectory(artifactsDir, path.join(outputDir, 'dist'), { - include: ['**/*'], - exclude: DIST_EXCLUDES, - }); -} - -async function applyHeadersToSourceJs( - outputDir: string, - licenseTemplatePath: string, - pkg: PackageJson, - eulaUrl: string, -): Promise { - const renderBanner = await buildLicenseBannerRenderer({ - templatePath: licenseTemplatePath, - pkg, - eulaUrl, - commentType: '*', - }); - const cwd = toPosixPath(outputDir); - const jsFiles = await glob('**/*.js', { - cwd, - nodir: true, - absolute: true, - ignore: [...SRC_JS_EXCLUDES, 'dist/**/*', 'bin/**/*', 'license/**/*'], - }); - await Promise.all( - jsFiles.map(async (filePath) => { - const fileRelative = path.relative(outputDir, filePath).replace(/\\/g, '/'); - const banner = renderBanner(fileRelative); - await applyLicenseBannerToFile(filePath, banner); - }), - ); -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - const transpiledDir = path.resolve(projectRoot, options.transpiledDir); - const jsSrcDir = path.resolve(projectRoot, options.jsSrcDir); - const licenseSrcDir = path.resolve(projectRoot, options.licenseSrcDir); - const npmBinDir = path.resolve(projectRoot, options.npmBinDir); - const webpackConfigSrc = path.resolve(projectRoot, options.webpackConfig); - const artifactsDir = path.resolve(projectRoot, options.artifactsDir); - const outputDir = path.resolve(projectRoot, options.outputDir); - const licenseTemplatePath = options.licenseTemplateFile - ? path.resolve(projectRoot, options.licenseTemplateFile) - : DEFAULT_LICENSE_TEMPLATE_EULA; - - let pkg: PackageJson; - try { - pkg = await readJson(path.join(projectRoot, 'package.json')); - } catch (error) { - logError('Failed to read package.json', error); - return { success: false }; - } - - try { - const webpackConfigDest = path.join(outputDir, 'bin', path.basename(webpackConfigSrc)); - - await Promise.all([ - copySourceJs(transpiledDir, outputDir), - copyEsmPackageJsonFiles(transpiledDir, outputDir), - copyJsSrcJsonFiles(jsSrcDir, outputDir), - copyLicenseFiles(licenseSrcDir, outputDir), - copyNpmBinFiles(npmBinDir, outputDir), - copyFile(webpackConfigSrc, webpackConfigDest), - copyDistFiles(artifactsDir, outputDir), - ]); - logger.verbose('Assembled npm package contents'); - - await applyHeadersToSourceJs( - outputDir, - licenseTemplatePath, - pkg, - options.eulaUrl ?? DEFAULT_EULA_URL, - ); - logger.verbose('Applied star-license banners to source JS files'); - - return { success: true }; - } catch (error) { - logError('NpmAssemble executor failed', error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './npm-assemble.impl'; diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts new file mode 100644 index 000000000000..72502add24e0 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts @@ -0,0 +1,180 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { glob } from 'glob'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { toPosixPath } from '../../utils/path-resolver'; +import { + copyFile, + ensureDir, + ensureTrailingNewline, + loadProjectPackageJson, + normalizeEol, + readFileText, + writeFileText, +} from '../../utils/file-operations'; +import { copyDirectory } from '../copy-files/copy-files.impl'; +import { applyLicenseHeadersToDirectory } from '../add-license-headers/add-license-headers.impl'; +import { DEFAULT_EULA_URL, resolveLicenseTemplate } from '../add-license-headers/defaults'; +import type { PackageJson } from '../../utils/types'; +import { NpmAssembleExecutorSchema } from './schema'; + +const SRC_JS_EXCLUDES = [ + 'bundles/*.js', + 'cjs/bundles/**/*', + 'esm/bundles/**/*', + 'bundles/modules/parts/*.js', + 'viz/vector_map.utils/*.js', + 'viz/docs/*.js', + '**/license/license_validation_internal.js', +]; + +const DIST_EXCLUDES = [ + 'transpiled**/**/*', + 'npm/**/*.*', + 'ts/jquery*', + 'ts/knockout*', + 'ts/globalize*', + 'ts/cldr*', + 'css/dx-diagram.*', + 'css/dx-gantt.*', + 'js/knockout*', + 'js/cldr/*.*', + 'js/cldr*', + 'js/globalize/*.*', + 'js/globalize*', + 'js/dx-exceljs-fork*', + 'js/file-saver*', + 'js/jquery*', + 'js/jspdf*', + 'js/jspdf-autotable*', + 'js/jszip*', + 'js/dx.custom*', + 'js/dx.viz*', + 'js/dx.web*', + 'js/dx-diagram*', + 'js/dx-gantt*', + 'js/dx-quill*', +]; + +const SRC_JS_HEADER_EXCLUDES = [...SRC_JS_EXCLUDES, 'dist/**/*', 'bin/**/*', 'license/**/*']; + +const VECTOR_MAP_UTILS_EXCLUDES = ['viz/vector_map.utils/**']; + +async function copySourceJs(transpiledDir: string, outputDir: string): Promise { + await copyDirectory(transpiledDir, outputDir, { + include: ['**/*.js'], + exclude: SRC_JS_EXCLUDES, + }); +} + +async function copyEsmPackageJsonFiles(transpiledDir: string, outputDir: string): Promise { + await copyDirectory(transpiledDir, outputDir, { + include: ['**/*.json'], + exclude: VECTOR_MAP_UTILS_EXCLUDES, + }); +} + +async function copyJsSrcJsonFiles(jsSrcDir: string, outputDir: string): Promise { + await copyDirectory(jsSrcDir, outputDir, { + include: ['**/*.json'], + exclude: VECTOR_MAP_UTILS_EXCLUDES, + }); +} + +async function copyAndNormalizeFiles( + sourceDir: string, + destinationDir: string, + pattern: string, +): Promise { + const cwd = toPosixPath(sourceDir); + const relativePaths = await glob(pattern, { cwd, nodir: true }); + await Promise.all( + relativePaths.map(async (relative) => { + const destination = path.join(destinationDir, relative); + await ensureDir(path.dirname(destination)); + await fs.copyFile(path.join(sourceDir, relative), destination); + const content = await readFileText(destination); + await writeFileText(destination, ensureTrailingNewline(normalizeEol(content))); + }), + ); +} + +async function copyLicenseFiles(licenseSrcDir: string, outputDir: string): Promise { + await copyAndNormalizeFiles(licenseSrcDir, path.join(outputDir, 'license'), '**/*'); +} + +async function copyNpmBinFiles(npmBinDir: string, outputDir: string): Promise { + await copyAndNormalizeFiles(npmBinDir, path.join(outputDir, 'bin'), '*.js'); +} + +async function copyDistFiles(artifactsDir: string, outputDir: string): Promise { + await copyDirectory(artifactsDir, path.join(outputDir, 'dist'), { + include: ['**/*'], + exclude: DIST_EXCLUDES, + }); +} + +interface ResolvedNpmAssemble { + pkg: PackageJson; + templatePath: string; + eulaUrl: string; + transpiledDir: string; + jsSrcDir: string; + licenseSrcDir: string; + npmBinDir: string; + webpackConfigSrc: string; + artifactsDir: string; + outputDir: string; +} + +export default createExecutor({ + name: 'NpmAssemble', + resolve: async (options, { projectRoot }) => { + const pkg = await loadProjectPackageJson(projectRoot); + const templatePath = resolveLicenseTemplate(projectRoot, options); + + return { + pkg, + templatePath, + eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, + transpiledDir: path.resolve(projectRoot, options.transpiledDir), + jsSrcDir: path.resolve(projectRoot, options.jsSrcDir), + licenseSrcDir: path.resolve(projectRoot, options.licenseSrcDir), + npmBinDir: path.resolve(projectRoot, options.npmBinDir), + webpackConfigSrc: path.resolve(projectRoot, options.webpackConfig), + artifactsDir: path.resolve(projectRoot, options.artifactsDir), + outputDir: path.resolve(projectRoot, options.outputDir), + }; + }, + run: async (resolved) => { + const webpackConfigDestination = path.join( + resolved.outputDir, + 'bin', + path.basename(resolved.webpackConfigSrc), + ); + + await Promise.all([ + copySourceJs(resolved.transpiledDir, resolved.outputDir), + copyEsmPackageJsonFiles(resolved.transpiledDir, resolved.outputDir), + copyJsSrcJsonFiles(resolved.jsSrcDir, resolved.outputDir), + copyLicenseFiles(resolved.licenseSrcDir, resolved.outputDir), + copyNpmBinFiles(resolved.npmBinDir, resolved.outputDir), + copyFile(resolved.webpackConfigSrc, webpackConfigDestination), + copyDistFiles(resolved.artifactsDir, resolved.outputDir), + ]); + logger.verbose('Assembled npm package contents'); + + await applyLicenseHeadersToDirectory({ + targetDir: resolved.outputDir, + pkg: resolved.pkg, + templatePath: resolved.templatePath, + eulaUrl: resolved.eulaUrl, + commentType: '*', + includePatterns: ['**/*.js'], + excludePatterns: SRC_JS_HEADER_EXCLUDES, + filenameMode: 'relative', + }); + logger.verbose('Applied star-license banners to source JS files'); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/pack-npm/executor.ts b/packages/nx-infra-plugin/src/executors/pack-npm/executor.ts index 9deaacde7b4c..93275597fdaf 100644 --- a/packages/nx-infra-plugin/src/executors/pack-npm/executor.ts +++ b/packages/nx-infra-plugin/src/executors/pack-npm/executor.ts @@ -1,41 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import { execSync } from 'child_process'; -import path from 'path'; -import { PackNpmExecutorSchema } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; - -const DEFAULT_DIST_DIR = './npm'; - -const MSG_PACK_SUCCESS = 'pnpm pack completed successfully'; -const MSG_PACK_FAILED = 'Failed to run pnpm pack'; - -const runExecutor: PromiseExecutor = async (options, context) => { - const absoluteProjectRoot = resolveProjectPath(context); - const distDirectory = options.workingDirectory || DEFAULT_DIST_DIR; - const workspaceRoot = context.root; - - if (!context.projectName) { - logError(MSG_PACK_FAILED, 'Project name is not defined in context'); - return { success: false }; - } - - try { - logger.verbose(`Running pnpm pack from ${absoluteProjectRoot} (packaging ${distDirectory})...`); - - const projectPath = path.join(workspaceRoot, 'packages', context.projectName); - - execSync(`pnpm pack`, { - cwd: projectPath, - stdio: 'inherit', - }); - - logger.verbose(MSG_PACK_SUCCESS); - return { success: true }; - } catch (error) { - logError(MSG_PACK_FAILED, error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './pack-npm.impl'; diff --git a/packages/nx-infra-plugin/src/executors/pack-npm/pack-npm.impl.ts b/packages/nx-infra-plugin/src/executors/pack-npm/pack-npm.impl.ts new file mode 100644 index 000000000000..d09ddb39e93b --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/pack-npm/pack-npm.impl.ts @@ -0,0 +1,40 @@ +import * as path from 'path'; +import { execSync } from 'child_process'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { PackNpmExecutorSchema } from './schema'; + +const DEFAULT_DIST_DIR = './npm'; + +const MSG_PACK_SUCCESS = 'pnpm pack completed successfully'; +const ERROR_PROJECT_NAME_MISSING = 'Project name is not defined in context'; + +interface ResolvedPackNpm { + projectRoot: string; + projectPath: string; + distDirectory: string; +} + +export default createExecutor({ + name: 'PackNpm', + resolve: (options, { projectRoot, context }) => { + if (!context.projectName) { + throw new Error(ERROR_PROJECT_NAME_MISSING); + } + const distDirectory = options.workingDirectory || DEFAULT_DIST_DIR; + const projectPath = path.join(context.root, 'packages', context.projectName); + return { projectRoot, projectPath, distDirectory }; + }, + run: async (resolved) => { + logger.verbose( + `Running pnpm pack from ${resolved.projectRoot} (packaging ${resolved.distDirectory})...`, + ); + + execSync(`pnpm pack`, { + cwd: resolved.projectPath, + stdio: 'inherit', + }); + + logger.verbose(MSG_PACK_SUCCESS); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts index 9795fadeb7b2..47757b2e1a98 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts @@ -50,7 +50,7 @@ describe('PreparePackageJsonExecutor E2E', () => { cleanupTempDir(tempDir); }); - it('should remove publishConfig from package.json', async () => { + it('should remove publishConfig by default and preserve all other fields', async () => { const options: NpmPackageExecutorSchema = { distDirectory: './npm', }; @@ -60,40 +60,20 @@ describe('PreparePackageJsonExecutor E2E', () => { expect(result.success).toBe(true); const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const distPackageJson = path.join(projectDir, 'npm', 'package.json'); - const distPackage = JSON.parse(await readFileText(distPackageJson)); + const distPackage = JSON.parse( + await readFileText(path.join(projectDir, 'npm', 'package.json')), + ); expect(distPackage.publishConfig).toBeUndefined(); - }); - - it('should preserve all other fields when removing publishConfig', async () => { - const options: NpmPackageExecutorSchema = { - distDirectory: './npm', - }; - - await executor(options, context); - - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const distPackageJson = path.join(projectDir, 'npm', 'package.json'); - const distPackage = JSON.parse(await readFileText(distPackageJson)); - expect(distPackage.name).toBe('@devexpress/test-package'); expect(distPackage.version).toBe('1.0.0'); expect(distPackage.description).toBe('Test package for prepare-package-json'); expect(distPackage.main).toBe('./index.js'); expect(distPackage.module).toBe('./esm/index.js'); expect(distPackage.types).toBe('./index.d.ts'); - expect(distPackage.scripts).toEqual({ - build: 'tsc', - test: 'jest', - }); - expect(distPackage.dependencies).toEqual({ - react: '^18.0.0', - }); - expect(distPackage.devDependencies).toEqual({ - typescript: '^4.9.0', - jest: '^29.0.0', - }); + expect(distPackage.scripts).toEqual({ build: 'tsc', test: 'jest' }); + expect(distPackage.dependencies).toEqual({ react: '^18.0.0' }); + expect(distPackage.devDependencies).toEqual({ typescript: '^4.9.0', jest: '^29.0.0' }); expect(distPackage.keywords).toEqual(['test', 'package']); expect(distPackage.license).toBe('MIT'); expect(distPackage.author).toBe('Test Author'); @@ -157,7 +137,7 @@ describe('PreparePackageJsonExecutor E2E', () => { expect(distPkg.version).toBe('9.8.7'); }); - it('should rename devextreme to devextreme-internal after setName via renameInternalPattern', async () => { + it('should apply renameInternalPattern after setName', async () => { const options: NpmPackageExecutorSchema = { distDirectory: './npm', setName: 'devextreme', @@ -174,44 +154,6 @@ describe('PreparePackageJsonExecutor E2E', () => { expect(distPkg.name).toBe('devextreme-internal'); }); - it('should rename devextreme-foo to devextreme-foo-internal via renameInternalPattern', async () => { - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - - await writeJson(path.join(projectDir, 'package.json'), { - name: 'devextreme-foo', - version: '1.0.0', - publishConfig: { access: 'public' }, - }); - - const options: NpmPackageExecutorSchema = { - distDirectory: './npm', - renameInternalPattern: { find: '^devextreme(-.*)?$', replace: 'devextreme$1-internal' }, - }; - - const result = await executor(options, context); - - expect(result.success).toBe(true); - - const distPkg = JSON.parse(await readFileText(path.join(projectDir, 'npm', 'package.json'))); - - expect(distPkg.name).toBe('devextreme-foo-internal'); - }); - - it('should remove only publishConfig by default when no new options are specified', async () => { - const options: NpmPackageExecutorSchema = { - distDirectory: './npm', - }; - - await executor(options, context); - - const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const distPkg = JSON.parse(await readFileText(path.join(projectDir, 'npm', 'package.json'))); - - expect(distPkg.publishConfig).toBeUndefined(); - expect(distPkg.devDependencies).toBeDefined(); - expect(distPkg.scripts).toBeDefined(); - }); - it('should remove nothing when removeFields is an empty array', async () => { const options: NpmPackageExecutorSchema = { distDirectory: './npm', diff --git a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts index bc07b4065c19..3a1086e07dce 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts +++ b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.ts @@ -1,97 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import { NpmPackageExecutorSchema } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; -import { ensureDir, readJson, writeJson } from '../../utils/file-operations'; - -const DEFAULT_SOURCE_PACKAGE_JSON = './package.json'; -const DEFAULT_DIST_DIR = './npm'; - -const PACKAGE_JSON_FILE = 'package.json'; -const PUBLISH_CONFIG_FIELD = 'publishConfig'; - -const JSON_INDENT = 2; - -const ERROR_PREPARE_PACKAGE_JSON = 'Failed to prepare package.json'; - -interface PackageJsonTransformations { - setName?: string; - setVersion?: string; - renameInternalPattern?: { find: string; replace: string }; - removeFields?: string[]; -} - -function applyPackageJsonTransformations( - pkg: Record, - transformations: PackageJsonTransformations, - versionFromValue?: unknown, -): Record { - const result: Record = { ...pkg }; - - if (transformations.setName !== undefined) { - result['name'] = transformations.setName; - } - - if (transformations.setVersion !== undefined) { - result['version'] = transformations.setVersion; - } else if (versionFromValue !== undefined) { - result['version'] = versionFromValue; - } - - if (transformations.renameInternalPattern !== undefined) { - const { find, replace } = transformations.renameInternalPattern; - result['name'] = String.prototype.replace.call( - String(result['name']), - new RegExp(find), - replace, - ); - } - - const fieldsToRemove = transformations.removeFields ?? [PUBLISH_CONFIG_FIELD]; - for (const field of fieldsToRemove) { - delete result[field]; - } - - return result; -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const absoluteProjectRoot = resolveProjectPath(context); - const sourcePackageJson = path.join( - absoluteProjectRoot, - options.sourcePackageJson || DEFAULT_SOURCE_PACKAGE_JSON, - ); - const distDirectory = path.join(absoluteProjectRoot, options.distDirectory || DEFAULT_DIST_DIR); - - try { - await ensureDir(distDirectory); - - const pkg = await readJson>(sourcePackageJson); - - let versionFromValue: unknown; - if (options.setVersion === undefined && options.versionFrom !== undefined) { - const versionSourcePath = path.join(absoluteProjectRoot, options.versionFrom); - const versionSource = await readJson>(versionSourcePath); - if (versionSource['version'] === undefined) { - throw new Error(`No 'version' field in ${versionSourcePath}`); - } - versionFromValue = versionSource['version']; - } - - const transformed = applyPackageJsonTransformations(pkg, options, versionFromValue); - - const outputFileName = options.outputFileName ?? PACKAGE_JSON_FILE; - const distPackageJson = path.join(distDirectory, outputFileName); - await writeJson(distPackageJson, transformed, JSON_INDENT); - - logger.verbose(`Created ${distPackageJson}`); - - return { success: true }; - } catch (error) { - logError(ERROR_PREPARE_PACKAGE_JSON, error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './prepare-package-json.impl'; diff --git a/packages/nx-infra-plugin/src/executors/prepare-package-json/prepare-package-json.impl.ts b/packages/nx-infra-plugin/src/executors/prepare-package-json/prepare-package-json.impl.ts new file mode 100644 index 000000000000..313ba5717a96 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/prepare-package-json/prepare-package-json.impl.ts @@ -0,0 +1,100 @@ +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { ensureDir, readJson, writeJson } from '../../utils/file-operations'; +import { NpmPackageExecutorSchema } from './schema'; + +const DEFAULT_SOURCE_PACKAGE_JSON = './package.json'; +const DEFAULT_DIST_DIR = './npm'; + +const PACKAGE_JSON_FILE = 'package.json'; +const PUBLISH_CONFIG_FIELD = 'publishConfig'; + +const JSON_INDENT = 2; + +interface PackageJsonTransformations { + setName?: string; + setVersion?: string; + renameInternalPattern?: { find: string; replace: string }; + removeFields?: string[]; +} + +function applyPackageJsonTransformations( + pkg: Record, + transformations: PackageJsonTransformations, + versionFromValue?: unknown, +): Record { + const result: Record = { ...pkg }; + + if (transformations.setName !== undefined) { + result['name'] = transformations.setName; + } + + if (transformations.setVersion !== undefined) { + result['version'] = transformations.setVersion; + } else if (versionFromValue !== undefined) { + result['version'] = versionFromValue; + } + + if (transformations.renameInternalPattern !== undefined) { + const { find, replace } = transformations.renameInternalPattern; + result['name'] = String.prototype.replace.call( + String(result['name']), + new RegExp(find), + replace, + ); + } + + const fieldsToRemove = transformations.removeFields ?? [PUBLISH_CONFIG_FIELD]; + for (const field of fieldsToRemove) { + delete result[field]; + } + + return result; +} + +interface ResolvedPreparePackageJson { + sourcePackageJson: string; + distDirectory: string; + outputFileName: string; + versionFromPath?: string; +} + +export default createExecutor({ + name: 'PreparePackageJson', + resolve: (options, { projectRoot }) => { + const sourcePackageJson = path.join( + projectRoot, + options.sourcePackageJson || DEFAULT_SOURCE_PACKAGE_JSON, + ); + const distDirectory = path.join(projectRoot, options.distDirectory || DEFAULT_DIST_DIR); + const outputFileName = options.outputFileName ?? PACKAGE_JSON_FILE; + const versionFromPath = + options.setVersion === undefined && options.versionFrom !== undefined + ? path.join(projectRoot, options.versionFrom) + : undefined; + + return { sourcePackageJson, distDirectory, outputFileName, versionFromPath }; + }, + run: async (resolved, options) => { + await ensureDir(resolved.distDirectory); + + const pkg = await readJson>(resolved.sourcePackageJson); + + let versionFromValue: unknown; + if (resolved.versionFromPath !== undefined) { + const versionSource = await readJson>(resolved.versionFromPath); + if (versionSource['version'] === undefined) { + throw new Error(`No 'version' field in ${resolved.versionFromPath}`); + } + versionFromValue = versionSource['version']; + } + + const transformed = applyPackageJsonTransformations(pkg, options, versionFromValue); + + const distPackageJson = path.join(resolved.distDirectory, resolved.outputFileName); + await writeJson(distPackageJson, transformed, JSON_INDENT); + + logger.verbose(`Created ${distPackageJson}`); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/prepare-submodules/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/prepare-submodules/executor.e2e.spec.ts index d6a088b184b5..29be7577bafe 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-submodules/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/prepare-submodules/executor.e2e.spec.ts @@ -53,7 +53,7 @@ describe('PrepareSubmodulesExecutor E2E', () => { }); describe('Basic functionality', () => { - it('should generate package.json files for discovered modules', async () => { + it('should generate package.json files for top-level modules with correct relative paths', async () => { const options: PrepareSubmodulesExecutorSchema = { distDirectory: './npm', }; @@ -63,20 +63,15 @@ describe('PrepareSubmodulesExecutor E2E', () => { expect(result.success).toBe(true); const npmDir = path.join(tempDir, 'packages', 'test-package', 'npm'); + const buttonPkg = JSON.parse(await readFileText(path.join(npmDir, 'button', 'package.json'))); - const buttonPkgPath = path.join(npmDir, 'button', 'package.json'); - expect(fs.existsSync(buttonPkgPath)).toBe(true); - - const buttonPkg = JSON.parse(await readFileText(buttonPkgPath)); - expect(buttonPkg).toMatchObject({ - sideEffects: false, - main: expect.stringContaining('cjs/button.js'), - module: expect.stringContaining('esm/button.js'), - typings: expect.stringContaining('cjs/button.d.ts'), - }); + expect(buttonPkg.sideEffects).toBe(false); + expect(buttonPkg.main).toBe('../cjs/button.js'); + expect(buttonPkg.module).toBe('../esm/button.js'); + expect(buttonPkg.typings).toBe('../cjs/button.d.ts'); }); - it('should handle nested module structures', async () => { + it('should discover nested module exports from the ESM index file', async () => { const options: PrepareSubmodulesExecutorSchema = { distDirectory: './npm', }; @@ -94,7 +89,7 @@ describe('PrepareSubmodulesExecutor E2E', () => { expect(gridPkg.module).toContain('esm/data/grid.js'); }); - it('should work with custom submodule files', async () => { + it('should pick up additional submodules added to the ESM index file', async () => { const npmDir = path.join(tempDir, 'packages', 'test-package', 'npm'); await writeFileText(path.join(npmDir, 'esm', 'custom.js'), 'export const Custom = {};'); @@ -121,7 +116,7 @@ describe('PrepareSubmodulesExecutor E2E', () => { expect(customPkg.module).toBe('../esm/custom.js'); }); - it('should handle devextreme-react-like structure', async () => { + it('should generate package.json files for explicit submoduleFolders entries', async () => { const npmDir = path.join(tempDir, 'packages', 'test-package', 'npm'); fs.mkdirSync(path.join(npmDir, 'esm', 'common'), { recursive: true }); @@ -158,21 +153,6 @@ describe('PrepareSubmodulesExecutor E2E', () => { }); describe('Package.json content validation', () => { - it('should generate correct relative paths for top-level modules', async () => { - const options: PrepareSubmodulesExecutorSchema = { - distDirectory: './npm', - }; - - await executor(options, context); - - const npmDir = path.join(tempDir, 'packages', 'test-package', 'npm'); - const buttonPkg = JSON.parse(await readFileText(path.join(npmDir, 'button', 'package.json'))); - - expect(buttonPkg.main).toBe('../cjs/button.js'); - expect(buttonPkg.module).toBe('../esm/button.js'); - expect(buttonPkg.typings).toBe('../cjs/button.d.ts'); - }); - it('should generate correct paths for nested folder modules with index.js', async () => { const npmDir = path.join(tempDir, 'packages', 'test-package', 'npm'); @@ -202,10 +182,6 @@ describe('PrepareSubmodulesExecutor E2E', () => { expect(commonCorePkg.main).toBe('../../cjs/common/core/index.js'); expect(commonCorePkg.module).toBe('../../esm/common/core/index.js'); expect(commonCorePkg.typings).toBe('../../cjs/common/core/index.d.ts'); - - expect(commonCorePkg.main).not.toBe('../../cjs/index.js'); - expect(commonCorePkg.module).not.toBe('../../esm/index.js'); - expect(commonCorePkg.typings).not.toBe('../../cjs/index.d.ts'); }); }); diff --git a/packages/nx-infra-plugin/src/executors/prepare-submodules/executor.ts b/packages/nx-infra-plugin/src/executors/prepare-submodules/executor.ts index 299d10e21133..6c588592ed92 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-submodules/executor.ts +++ b/packages/nx-infra-plugin/src/executors/prepare-submodules/executor.ts @@ -1,162 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as fs from 'fs/promises'; -import * as fsSync from 'fs'; -import type { Dirent } from 'fs'; -import * as path from 'path'; -import { PrepareSubmodulesExecutorSchema } from './schema'; -import { PackParam } from '../../utils/types'; -import { resolveProjectPath } from '../../utils/path-resolver'; -import { logError, getErrorMessage } from '../../utils/error-handler'; -import { ensureDir, writeJson } from '../../utils/file-operations'; - -const DEFAULT_DIST_DIR = './npm'; -const ESM_DIR = 'esm'; -const CJS_DIR = 'cjs'; - -const ENCODING_UTF8 = 'utf8'; - -const JS_EXTENSION = '.js'; -const DTS_EXTENSION = '.d.ts'; - -const REGEX_IMPORTS = /from "\.\/([^;]+)";/g; -const REGEX_PARSE_MODULE = /((.*)\/)?([^/]+$)/; - -const MSG_PREPARING = '📦 Preparing submodules'; -const MSG_SUCCESS = '✓ Submodules prepared successfully'; -const ERROR_PREPARE_SUBMODULES = 'Failed to prepare submodules'; - -const INDEX_FILE_NAME = 'index.js'; -const PACKAGE_JSON_FILE = 'package.json'; - -const PATH_SLASH = '/'; -const RELATIVE_DIR_PREFIX = '../'; - -const DEFAULT_SUBMODULE_FOLDERS: PackParam[] = [ - ['common'], - ['core', ['template', 'config', 'nested-option', 'component', 'extension-component']], - ['common/core'], - ['common/data'], - ['common/export'], -]; - -const runExecutor: PromiseExecutor = async (options, context) => { - const absoluteProjectRoot = resolveProjectPath(context); - const distDirectory = path.join(absoluteProjectRoot, options.distDirectory || DEFAULT_DIST_DIR); - - try { - logger.verbose(MSG_PREPARING); - - if (options.submoduleFolders) { - logger.verbose( - `Using custom submoduleFolders: ${JSON.stringify(options.submoduleFolders, null, 2)}`, - ); - } - - const packParamsForFolders = options.submoduleFolders || DEFAULT_SUBMODULE_FOLDERS; - - const esmIndexPath = path.join(distDirectory, ESM_DIR, 'index.js'); - let modulesImportsFromIndex = ''; - - if (fsSync.existsSync(esmIndexPath)) { - modulesImportsFromIndex = await fs.readFile(esmIndexPath, ENCODING_UTF8); - } - - const modulesPaths = modulesImportsFromIndex.matchAll(REGEX_IMPORTS); - const packParamsForModules: PackParam[] = Array.from(modulesPaths).map(([, modulePath]) => { - const match = modulePath.match(REGEX_PARSE_MODULE) || []; - const moduleFilePath = match[2] as string | undefined; - const moduleFileName = match[3] as string | undefined; - - return ['', moduleFileName ? [moduleFileName] : undefined, moduleFilePath]; - }); - - const allModuleParams: PackParam[] = [...packParamsForModules, ...packParamsForFolders]; - - logger.verbose(`Processing ${allModuleParams.length} submodules...`); - - await Promise.all( - allModuleParams.map(([folder, moduleFileNames, moduleFilePath]) => - makeModule(distDirectory, folder, moduleFileNames, moduleFilePath), - ), - ); - - logger.verbose(MSG_SUCCESS); - return { success: true }; - } catch (error) { - logError(ERROR_PREPARE_SUBMODULES, error); - return { success: false }; - } -}; - -async function makeModule( - distFolder: string, - folder: string, - moduleFileNames?: string[], - moduleFilePath?: string, -): Promise { - const distModuleFolder = path.join(distFolder, folder); - const distEsmFolder = path.join(distFolder, ESM_DIR, folder); - const moduleNames = moduleFileNames || (await findJsModuleFileNamesInFolder(distEsmFolder)); - - try { - await ensureDir(distModuleFolder); - - if (folder && fsSync.existsSync(path.join(distEsmFolder, 'index.js'))) { - await generatePackageJsonFile(distFolder, folder, undefined, folder); - } - - await Promise.all( - moduleNames.map(async (moduleFileName) => { - const moduleDir = path.join(distModuleFolder, moduleFileName); - await ensureDir(moduleDir); - - await generatePackageJsonFile(distFolder, folder, moduleFileName, moduleFilePath || folder); - }), - ); - } catch (error) { - throw new Error(`Exception while makeModule(${folder}): ${getErrorMessage(error)}`); - } -} - -async function generatePackageJsonFile( - distFolder: string, - folder: string, - moduleFileName?: string, - filePath?: string, -): Promise { - const moduleName = moduleFileName || ''; - const absoluteModulePath = path.join(distFolder, folder, moduleName); - const moduleFilePathResolved = (filePath ? filePath + PATH_SLASH : '') + (moduleName || 'index'); - const esmFilePath = path.join(distFolder, ESM_DIR, moduleFilePathResolved + JS_EXTENSION); - const relativePath = path.relative(absoluteModulePath, esmFilePath); - - const relativeBase = RELATIVE_DIR_PREFIX.repeat(relativePath.split('..').length - 1); - - const packageJson = { - sideEffects: false, - main: `${relativeBase}${CJS_DIR}/${moduleFilePathResolved}${JS_EXTENSION}`, - module: `${relativeBase}${ESM_DIR}/${moduleFilePathResolved}${JS_EXTENSION}`, - typings: `${relativeBase}${CJS_DIR}/${moduleFilePathResolved}${DTS_EXTENSION}`, - }; - - await ensureDir(absoluteModulePath); - await writeJson(path.join(absoluteModulePath, PACKAGE_JSON_FILE), packageJson); -} - -async function findJsModuleFileNamesInFolder(dir: string): Promise { - if (!fsSync.existsSync(dir)) { - return []; - } - - const entries = await fs.readdir(dir, { withFileTypes: true }); - - return entries.filter(isJsModule).map((entry) => path.parse(entry.name).name); -} - -function isJsModule(entry: Dirent): boolean { - return ( - !entry.isDirectory() && entry.name.endsWith(JS_EXTENSION) && entry.name !== INDEX_FILE_NAME - ); -} - -export default runExecutor; +export { default } from './prepare-submodules.impl'; diff --git a/packages/nx-infra-plugin/src/executors/prepare-submodules/prepare-submodules.impl.ts b/packages/nx-infra-plugin/src/executors/prepare-submodules/prepare-submodules.impl.ts new file mode 100644 index 000000000000..90dab90e0c4f --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/prepare-submodules/prepare-submodules.impl.ts @@ -0,0 +1,166 @@ +import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; +import type { Dirent } from 'fs'; +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { getErrorMessage } from '../../utils/error-handler'; +import { ensureDir, writeJson } from '../../utils/file-operations'; +import { PackParam } from '../../utils/types'; +import { PrepareSubmodulesExecutorSchema } from './schema'; + +const DEFAULT_DIST_DIR = './npm'; +const ESM_DIR = 'esm'; +const CJS_DIR = 'cjs'; + +const ENCODING_UTF8 = 'utf8'; + +const JS_EXTENSION = '.js'; +const DTS_EXTENSION = '.d.ts'; + +const REGEX_IMPORTS = /from "\.\/([^;]+)";/g; +const REGEX_PARSE_MODULE = /((.*)\/)?([^/]+$)/; + +const MSG_PREPARING = '📦 Preparing submodules'; +const MSG_SUCCESS = '✓ Submodules prepared successfully'; + +const INDEX_FILE_NAME = 'index.js'; +const PACKAGE_JSON_FILE = 'package.json'; + +const PATH_SLASH = '/'; +const RELATIVE_DIR_PREFIX = '../'; + +const DEFAULT_SUBMODULE_FOLDERS: PackParam[] = [ + ['common'], + ['core', ['template', 'config', 'nested-option', 'component', 'extension-component']], + ['common/core'], + ['common/data'], + ['common/export'], +]; + +function isJsModule(entry: Dirent): boolean { + return ( + !entry.isDirectory() && entry.name.endsWith(JS_EXTENSION) && entry.name !== INDEX_FILE_NAME + ); +} + +async function findJsModuleFileNamesInFolder(dir: string): Promise { + if (!fsSync.existsSync(dir)) { + return []; + } + + const entries = await fs.readdir(dir, { withFileTypes: true }); + + return entries.filter(isJsModule).map((entry) => path.parse(entry.name).name); +} + +async function generatePackageJsonFile( + distFolder: string, + folder: string, + moduleFileName?: string, + filePath?: string, +): Promise { + const moduleName = moduleFileName || ''; + const absoluteModulePath = path.join(distFolder, folder, moduleName); + const moduleFilePathResolved = (filePath ? filePath + PATH_SLASH : '') + (moduleName || 'index'); + const esmFilePath = path.join(distFolder, ESM_DIR, moduleFilePathResolved + JS_EXTENSION); + const relativePath = path.relative(absoluteModulePath, esmFilePath); + + const relativeBase = RELATIVE_DIR_PREFIX.repeat(relativePath.split('..').length - 1); + + const packageJson = { + sideEffects: false, + main: `${relativeBase}${CJS_DIR}/${moduleFilePathResolved}${JS_EXTENSION}`, + module: `${relativeBase}${ESM_DIR}/${moduleFilePathResolved}${JS_EXTENSION}`, + typings: `${relativeBase}${CJS_DIR}/${moduleFilePathResolved}${DTS_EXTENSION}`, + }; + + await ensureDir(absoluteModulePath); + await writeJson(path.join(absoluteModulePath, PACKAGE_JSON_FILE), packageJson); +} + +async function makeModule( + distFolder: string, + folder: string, + moduleFileNames?: string[], + moduleFilePath?: string, +): Promise { + const distModuleFolder = path.join(distFolder, folder); + const distEsmFolder = path.join(distFolder, ESM_DIR, folder); + const moduleNames = moduleFileNames || (await findJsModuleFileNamesInFolder(distEsmFolder)); + + try { + await ensureDir(distModuleFolder); + + if (folder && fsSync.existsSync(path.join(distEsmFolder, 'index.js'))) { + await generatePackageJsonFile(distFolder, folder, undefined, folder); + } + + await Promise.all( + moduleNames.map(async (moduleFileName) => { + const moduleDir = path.join(distModuleFolder, moduleFileName); + await ensureDir(moduleDir); + + await generatePackageJsonFile(distFolder, folder, moduleFileName, moduleFilePath || folder); + }), + ); + } catch (error) { + throw new Error(`Exception while makeModule(${folder}): ${getErrorMessage(error)}`); + } +} + +interface ResolvedPrepareSubmodules { + distDirectory: string; + packParamsForFolders: PackParam[]; + customSubmoduleFoldersProvided: boolean; +} + +export default createExecutor({ + name: 'PrepareSubmodules', + resolve: (options, { projectRoot }) => { + const distDirectory = path.join(projectRoot, options.distDirectory || DEFAULT_DIST_DIR); + const packParamsForFolders = options.submoduleFolders || DEFAULT_SUBMODULE_FOLDERS; + const customSubmoduleFoldersProvided = options.submoduleFolders !== undefined; + return { distDirectory, packParamsForFolders, customSubmoduleFoldersProvided }; + }, + run: async (resolved, options) => { + logger.verbose(MSG_PREPARING); + + if (resolved.customSubmoduleFoldersProvided) { + logger.verbose( + `Using custom submoduleFolders: ${JSON.stringify(options.submoduleFolders, null, 2)}`, + ); + } + + const esmIndexPath = path.join(resolved.distDirectory, ESM_DIR, 'index.js'); + let modulesImportsFromIndex = ''; + + if (fsSync.existsSync(esmIndexPath)) { + modulesImportsFromIndex = await fs.readFile(esmIndexPath, ENCODING_UTF8); + } + + const modulesPaths = modulesImportsFromIndex.matchAll(REGEX_IMPORTS); + const packParamsForModules: PackParam[] = Array.from(modulesPaths).map(([, modulePath]) => { + const match = modulePath.match(REGEX_PARSE_MODULE) || []; + const moduleFilePath = match[2] as string | undefined; + const moduleFileName = match[3] as string | undefined; + + return ['', moduleFileName ? [moduleFileName] : undefined, moduleFilePath]; + }); + + const allModuleParams: PackParam[] = [ + ...packParamsForModules, + ...resolved.packParamsForFolders, + ]; + + logger.verbose(`Processing ${allModuleParams.length} submodules...`); + + await Promise.all( + allModuleParams.map(([folder, moduleFileNames, moduleFilePath]) => + makeModule(resolved.distDirectory, folder, moduleFileNames, moduleFilePath), + ), + ); + + logger.verbose(MSG_SUCCESS); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts index 6b97e8ad76aa..bebf5fbea714 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts @@ -83,17 +83,4 @@ describe('ScssAssembleExecutor E2E', () => { expect(content).toContain(expectedSvg); expect(content).toContain(expectedPng); }); - - it('should pass through scss files without data-uri references unchanged', async () => { - const plainContent = '.button { color: red; }\n.icon { display: inline-block; }\n'; - await writeFileText(path.join(scssPackageDir, 'scss', 'plain.scss'), plainContent); - - const context = createMockContext({ root: tempDir }); - const result = await executor(OPTIONS, context); - - expect(result.success).toBe(true); - - const content = await readFileText(path.join(outputDir, 'plain.scss')); - expect(content).toBe(plainContent); - }); }); diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts index 5d170eea318e..ac5b9358fc1c 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts @@ -1,109 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import * as fs from 'fs/promises'; -import { glob } from 'glob'; -import { ScssAssembleExecutorSchema } from './schema'; -import { resolveProjectPath, toPosixPath } from '../../utils/path-resolver'; -import { logError } from '../../utils/error-handler'; -import { readFileText, writeFileText, ensureDir } from '../../utils/file-operations'; -import { copyDirectory } from '../../utils/copy-directory'; - -const DATA_URI_REGEX = /data-uri\((?:'(image\/svg\+xml;charset=UTF-8)',\s)?['"]?([^)'"]+)['"]?\)/g; - -const SCSS_EXTENSIONS = new Set(['.scss', '.css']); - -function encodeSvg(buffer: Buffer, svgEncoding?: string): string { - const encoding = svgEncoding ?? 'image/svg+xml;charset=UTF-8'; - return `"data:${encoding},${encodeURIComponent(buffer.toString())}"`; -} - -function encodeImage(buffer: Buffer, ext: string): string { - return `"data:image/${ext};base64,${buffer.toString('base64')}"`; -} - -async function inlineDataUri(content: string, scssRoot: string): Promise { - const matches = [...content.matchAll(DATA_URI_REGEX)]; - if (matches.length === 0) return content; - - const replacements = new Map(); - - await Promise.all( - matches.map(async (match) => { - const matchStr = match[0]; - if (replacements.has(matchStr)) return; - - const svgEncoding = match[1]; - const fileName = match[2]; - const filePath = path.resolve(scssRoot, fileName); - const ext = path.extname(filePath).slice(1); - const buffer = await fs.readFile(filePath); - const escapedString = - ext === 'svg' ? encodeSvg(buffer, svgEncoding) : encodeImage(buffer, ext); - replacements.set(matchStr, `url(${escapedString})`); - }), - ); - - return content.replace(DATA_URI_REGEX, (match) => replacements.get(match) ?? match); -} - -async function copyScssWithInlineDataUri( - scssPackagePath: string, - outputDir: string, -): Promise { - const scssSourceDir = path.join(scssPackagePath, 'scss'); - const cwd = toPosixPath(scssSourceDir); - const relPaths = await glob('**/*', { cwd, nodir: true }); - - await Promise.all( - relPaths.map(async (relPath) => { - const src = path.join(scssSourceDir, relPath); - const dest = path.join(outputDir, relPath); - const ext = path.extname(relPath).toLowerCase(); - - if (SCSS_EXTENSIONS.has(ext)) { - const content = await readFileText(src); - const inlined = await inlineDataUri(content, scssPackagePath); - await writeFileText(dest, inlined); - } else { - await ensureDir(path.dirname(dest)); - await fs.copyFile(src, dest); - } - }), - ); -} - -async function copyFonts(scssPackagePath: string, outputDir: string): Promise { - await copyDirectory( - path.join(scssPackagePath, 'fonts'), - path.join(outputDir, 'widgets/material/typography/fonts'), - ); -} - -async function copyIcons(scssPackagePath: string, outputDir: string): Promise { - await copyDirectory( - path.join(scssPackagePath, 'icons'), - path.join(outputDir, 'widgets/base/icons'), - ); -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - const scssPackagePath = path.resolve(projectRoot, options.scssPackagePath); - const outputDir = path.resolve(projectRoot, options.outputDir); - - try { - await Promise.all([ - copyScssWithInlineDataUri(scssPackagePath, outputDir), - copyFonts(scssPackagePath, outputDir), - copyIcons(scssPackagePath, outputDir), - ]); - logger.verbose('Assembled SCSS package contents'); - - return { success: true }; - } catch (error) { - logError('ScssAssemble executor failed', error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './scss-assemble.impl'; diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts new file mode 100644 index 000000000000..3c41547c3547 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts @@ -0,0 +1,109 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { glob } from 'glob'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { toPosixPath } from '../../utils/path-resolver'; +import { readFileText, writeFileText, ensureDir } from '../../utils/file-operations'; +import { copyDirectory } from '../copy-files/copy-files.impl'; +import { ScssAssembleExecutorSchema } from './schema'; + +const DATA_URI_REGEX = /data-uri\((?:'(image\/svg\+xml;charset=UTF-8)',\s)?['"]?([^)'"]+)['"]?\)/g; + +const SCSS_EXTENSIONS = new Set(['.scss', '.css']); + +function encodeSvg(buffer: Buffer, svgEncoding?: string): string { + const encoding = svgEncoding ?? 'image/svg+xml;charset=UTF-8'; + return `"data:${encoding},${encodeURIComponent(buffer.toString())}"`; +} + +function encodeImage(buffer: Buffer, ext: string): string { + return `"data:image/${ext};base64,${buffer.toString('base64')}"`; +} + +async function inlineDataUri(content: string, scssRoot: string): Promise { + const matches = [...content.matchAll(DATA_URI_REGEX)]; + if (matches.length === 0) return content; + + const replacements = new Map(); + + await Promise.all( + matches.map(async (match) => { + const matchStr = match[0]; + if (replacements.has(matchStr)) return; + + const svgEncoding = match[1]; + const fileName = match[2]; + const filePath = path.resolve(scssRoot, fileName); + const ext = path.extname(filePath).slice(1); + const buffer = await fs.readFile(filePath); + const escapedString = + ext === 'svg' ? encodeSvg(buffer, svgEncoding) : encodeImage(buffer, ext); + replacements.set(matchStr, `url(${escapedString})`); + }), + ); + + return content.replace(DATA_URI_REGEX, (match) => replacements.get(match) ?? match); +} + +async function copyScssWithInlineDataUri( + scssPackagePath: string, + outputDir: string, +): Promise { + const scssSourceDir = path.join(scssPackagePath, 'scss'); + const cwd = toPosixPath(scssSourceDir); + const relPaths = await glob('**/*', { cwd, nodir: true }); + + await Promise.all( + relPaths.map(async (relPath) => { + const src = path.join(scssSourceDir, relPath); + const dest = path.join(outputDir, relPath); + const ext = path.extname(relPath).toLowerCase(); + + if (SCSS_EXTENSIONS.has(ext)) { + const content = await readFileText(src); + const inlined = await inlineDataUri(content, scssPackagePath); + await writeFileText(dest, inlined); + } else { + await ensureDir(path.dirname(dest)); + await fs.copyFile(src, dest); + } + }), + ); +} + +async function copyFonts(scssPackagePath: string, outputDir: string): Promise { + await copyDirectory( + path.join(scssPackagePath, 'fonts'), + path.join(outputDir, 'widgets/material/typography/fonts'), + ); +} + +async function copyIcons(scssPackagePath: string, outputDir: string): Promise { + await copyDirectory( + path.join(scssPackagePath, 'icons'), + path.join(outputDir, 'widgets/base/icons'), + ); +} + +interface ResolvedScssAssemble { + scssPackagePath: string; + outputDir: string; +} + +export default createExecutor({ + name: 'ScssAssemble', + resolve: (options, { projectRoot }) => { + const scssPackagePath = path.resolve(projectRoot, options.scssPackagePath); + const outputDir = path.resolve(projectRoot, options.outputDir); + return { scssPackagePath, outputDir }; + }, + run: async ({ scssPackagePath, outputDir }) => { + await Promise.all([ + copyScssWithInlineDataUri(scssPackagePath, outputDir), + copyFonts(scssPackagePath, outputDir), + copyIcons(scssPackagePath, outputDir), + ]); + logger.verbose('Assembled SCSS package contents'); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/vectormap/executor.ts b/packages/nx-infra-plugin/src/executors/vectormap/executor.ts index 4a5ee72be7d8..f970ea73149d 100644 --- a/packages/nx-infra-plugin/src/executors/vectormap/executor.ts +++ b/packages/nx-infra-plugin/src/executors/vectormap/executor.ts @@ -1,229 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import * as fs from 'fs'; -import * as _ from 'lodash'; -import { VectormapExecutorSchema } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; -import { - ensureDir, - readFileText, - writeFileText, - normalizeEol, - ensureTrailingNewline, -} from '../../utils/file-operations'; - -interface UtilsSettings { - commonFiles: string[]; - browser: { fileName: string; files: string[] }; -} - -interface VariantConfig { - name: string; - files: string[]; - fileName: string; - suffix: string; -} - -interface ParsedRegion { - name: string; - data: unknown; -} - -type ParseFn = ( - input: { shp: ArrayBuffer; dbf: ArrayBuffer }, - options: { precision: number }, -) => unknown; - -const USE_STRICT_HEADER = '"use strict";\n\n'; -const DEBUG_SUFFIX = '.debug'; -const DEFAULT_PRECISION = 4; - -function toArrayBuffer(buffer: Buffer): ArrayBuffer { - return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); -} - -async function buildUtilsVariant( - variant: VariantConfig, - sourceDir: string, - utilsTemplate: string, - outDir: string, -): Promise { - const contents: string[] = []; - for (const file of variant.files) { - const filePath = path.join(sourceDir, `${file}.js`); - const content = await readFileText(filePath); - contents.push(content); - } - const concatenated = contents.join('\n'); - - const compiled = _.template(utilsTemplate); - let bundle = compiled({ data: concatenated }); - - bundle = ensureTrailingNewline(normalizeEol(bundle)); - - const outputPath = path.join(outDir, `${variant.fileName}${variant.suffix}.js`); - await writeFileText(outputPath, bundle); -} - -async function buildUtils( - sourceDir: string, - settingsFile: string, - utilsTemplatePath: string, - outDir: string, -): Promise<{ debugBundlePath: string }> { - const settingsPath = path.join(sourceDir, settingsFile); - const settings: UtilsSettings = JSON.parse(await readFileText(settingsPath)); - const utilsTemplate = await readFileText(utilsTemplatePath); - - const browserFiles = [...settings.commonFiles, ...settings.browser.files]; - const variants: VariantConfig[] = [ - { - name: 'browser-debug', - files: browserFiles, - fileName: settings.browser.fileName, - suffix: DEBUG_SUFFIX, - }, - { - name: 'browser-prod', - files: browserFiles, - fileName: settings.browser.fileName, - suffix: '', - }, - ]; - - await ensureDir(outDir); - - for (const variant of variants) { - logger.verbose(`Building utils variant: ${variant.name}`); - await buildUtilsVariant(variant, sourceDir, utilsTemplate, outDir); - } - - const debugBundlePath = path.join(outDir, `${settings.browser.fileName}${DEBUG_SUFFIX}.js`); - return { debugBundlePath }; -} - -function parseShapefiles(parse: ParseFn, sourcesDir: string, precision: number): ParsedRegion[] { - const shpFiles = fs - .readdirSync(sourcesDir) - .filter((f) => path.extname(f).toLowerCase() === '.shp') - .map((f) => path.basename(f, '.shp')); - - const regions: ParsedRegion[] = []; - for (const name of shpFiles) { - const shpBuffer = fs.readFileSync(path.join(sourcesDir, `${name}.shp`)); - const dbfBuffer = fs.readFileSync(path.join(sourcesDir, `${name}.dbf`)); - - const data = parse( - { shp: toArrayBuffer(shpBuffer), dbf: toArrayBuffer(dbfBuffer) }, - { precision }, - ); - - if (!data) { - throw new Error( - `Vectormap: parse() returned no data for "${name}". ` - + `Check that "${name}.shp" and "${name}.dbf" are valid shapefiles.`, - ); - } - - regions.push({ name, data }); - } - - return regions; -} - -async function writeRegionModules( - regions: ParsedRegion[], - dataTemplate: string, - outDir: string, -): Promise { - const compiled = _.template(dataTemplate); - - for (const { name, data } of regions) { - const rawData = `${name} = ${JSON.stringify(data)};`; - let wrapped = USE_STRICT_HEADER + compiled({ data: rawData }); - wrapped = ensureTrailingNewline(normalizeEol(wrapped)); - await writeFileText(path.join(outDir, `${name}.js`), wrapped); - } -} - -async function buildData( - debugBundlePath: string, - sourcesDir: string, - sourcesSettingsFile: string, - dataTemplatePath: string, - outDir: string, - projectRoot: string, -): Promise { - const dataTemplate = await readFileText(dataTemplatePath); - - const resolvedUtilPath = path.resolve(debugBundlePath); - delete require.cache[require.resolve(resolvedUtilPath)]; - const { parse } = require(resolvedUtilPath) as { parse: ParseFn }; - - const resolvedSourcesDir = path.resolve(projectRoot, sourcesDir); - const resolvedOutDir = path.resolve(projectRoot, outDir); - const resolvedSettingsPath = path.resolve(resolvedSourcesDir, sourcesSettingsFile); - - delete require.cache[require.resolve(resolvedSettingsPath)]; - const sourcesSettings = require(resolvedSettingsPath) as { precision?: number }; - const precision = - sourcesSettings.precision !== undefined && sourcesSettings.precision >= 0 - ? Math.round(sourcesSettings.precision) - : DEFAULT_PRECISION; - - await ensureDir(resolvedOutDir); - - const regions = parseShapefiles(parse, resolvedSourcesDir, precision); - await writeRegionModules(regions, dataTemplate, resolvedOutDir); -} - -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); - const { - sourceDir, - settingsFile, - sourcesDir, - sourcesSettingsFile, - utilsOutDir, - dataOutDir, - utilsTemplatePath, - dataTemplatePath, - } = options; - - const resolvedSourceDir = path.resolve(projectRoot, sourceDir); - const resolvedUtilsOutDir = path.resolve(projectRoot, utilsOutDir); - const resolvedUtilsTemplatePath = path.resolve(projectRoot, utilsTemplatePath); - const resolvedDataTemplatePath = path.resolve(projectRoot, dataTemplatePath); - - try { - logger.verbose('Phase 1: Building vectormap utilities...'); - const { debugBundlePath } = await buildUtils( - resolvedSourceDir, - settingsFile, - resolvedUtilsTemplatePath, - resolvedUtilsOutDir, - ); - - logger.verbose('Phase 2: Building vectormap data...'); - await buildData( - debugBundlePath, - sourcesDir, - sourcesSettingsFile, - resolvedDataTemplatePath, - dataOutDir, - projectRoot, - ); - const dataFiles = fs - .readdirSync(path.resolve(projectRoot, dataOutDir)) - .filter((f) => f.endsWith('.js')); - logger.verbose(`Phase 2 complete: ${dataFiles.length} region modules produced`); - - return { success: true }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logger.error(`Vectormap build failed: ${msg}`); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './vectormap.impl'; diff --git a/packages/nx-infra-plugin/src/executors/vectormap/vectormap.impl.ts b/packages/nx-infra-plugin/src/executors/vectormap/vectormap.impl.ts new file mode 100644 index 000000000000..28d3b82f0472 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/vectormap/vectormap.impl.ts @@ -0,0 +1,230 @@ +import { logger } from '@nx/devkit'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as _ from 'lodash'; +import { createExecutor } from '../../utils/create-executor'; +import { VectormapExecutorSchema } from './schema'; +import { + ensureDir, + readFileText, + writeFileText, + normalizeEol, + ensureTrailingNewline, +} from '../../utils/file-operations'; + +interface UtilsSettings { + commonFiles: string[]; + browser: { fileName: string; files: string[] }; +} + +interface VariantConfig { + name: string; + files: string[]; + fileName: string; + suffix: string; +} + +interface ParsedRegion { + name: string; + data: unknown; +} + +type ParseFn = ( + input: { shp: ArrayBuffer; dbf: ArrayBuffer }, + options: { precision: number }, +) => unknown; + +const USE_STRICT_HEADER = '"use strict";\n\n'; +const DEBUG_SUFFIX = '.debug'; +const DEFAULT_PRECISION = 4; + +function toArrayBuffer(buffer: Buffer): ArrayBuffer { + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); +} + +async function buildUtilsVariant( + variant: VariantConfig, + sourceDir: string, + utilsTemplate: string, + outDir: string, +): Promise { + const contents: string[] = []; + for (const file of variant.files) { + const filePath = path.join(sourceDir, `${file}.js`); + const content = await readFileText(filePath); + contents.push(content); + } + const concatenated = contents.join('\n'); + + const compiled = _.template(utilsTemplate); + let bundle = compiled({ data: concatenated }); + + bundle = ensureTrailingNewline(normalizeEol(bundle)); + + const outputPath = path.join(outDir, `${variant.fileName}${variant.suffix}.js`); + await writeFileText(outputPath, bundle); +} + +async function buildUtils( + sourceDir: string, + settingsFile: string, + utilsTemplatePath: string, + outDir: string, +): Promise<{ debugBundlePath: string }> { + const settingsPath = path.join(sourceDir, settingsFile); + const settings: UtilsSettings = JSON.parse(await readFileText(settingsPath)); + const utilsTemplate = await readFileText(utilsTemplatePath); + + const browserFiles = [...settings.commonFiles, ...settings.browser.files]; + const variants: VariantConfig[] = [ + { + name: 'browser-debug', + files: browserFiles, + fileName: settings.browser.fileName, + suffix: DEBUG_SUFFIX, + }, + { + name: 'browser-prod', + files: browserFiles, + fileName: settings.browser.fileName, + suffix: '', + }, + ]; + + await ensureDir(outDir); + + for (const variant of variants) { + logger.verbose(`Building utils variant: ${variant.name}`); + await buildUtilsVariant(variant, sourceDir, utilsTemplate, outDir); + } + + const debugBundlePath = path.join(outDir, `${settings.browser.fileName}${DEBUG_SUFFIX}.js`); + return { debugBundlePath }; +} + +function parseShapefiles(parse: ParseFn, sourcesDir: string, precision: number): ParsedRegion[] { + const shpFiles = fs + .readdirSync(sourcesDir) + .filter((entry) => path.extname(entry).toLowerCase() === '.shp') + .map((entry) => path.basename(entry, '.shp')); + + const regions: ParsedRegion[] = []; + for (const name of shpFiles) { + const shpBuffer = fs.readFileSync(path.join(sourcesDir, `${name}.shp`)); + const dbfBuffer = fs.readFileSync(path.join(sourcesDir, `${name}.dbf`)); + + const data = parse( + { shp: toArrayBuffer(shpBuffer), dbf: toArrayBuffer(dbfBuffer) }, + { precision }, + ); + + if (!data) { + throw new Error( + `Vectormap: parse() returned no data for "${name}". ` + + `Check that "${name}.shp" and "${name}.dbf" are valid shapefiles.`, + ); + } + + regions.push({ name, data }); + } + + return regions; +} + +async function writeRegionModules( + regions: ParsedRegion[], + dataTemplate: string, + outDir: string, +): Promise { + const compiled = _.template(dataTemplate); + + for (const { name, data } of regions) { + const rawData = `${name} = ${JSON.stringify(data)};`; + let wrapped = USE_STRICT_HEADER + compiled({ data: rawData }); + wrapped = ensureTrailingNewline(normalizeEol(wrapped)); + await writeFileText(path.join(outDir, `${name}.js`), wrapped); + } +} + +async function buildData( + debugBundlePath: string, + sourcesDir: string, + sourcesSettingsFile: string, + dataTemplatePath: string, + outDir: string, + projectRoot: string, +): Promise { + const dataTemplate = await readFileText(dataTemplatePath); + + const resolvedUtilPath = path.resolve(debugBundlePath); + delete require.cache[require.resolve(resolvedUtilPath)]; + const { parse } = require(resolvedUtilPath) as { parse: ParseFn }; + + const resolvedSourcesDir = path.resolve(projectRoot, sourcesDir); + const resolvedOutDir = path.resolve(projectRoot, outDir); + const resolvedSettingsPath = path.resolve(resolvedSourcesDir, sourcesSettingsFile); + + delete require.cache[require.resolve(resolvedSettingsPath)]; + const sourcesSettings = require(resolvedSettingsPath) as { precision?: number }; + const precision = + sourcesSettings.precision !== undefined && sourcesSettings.precision >= 0 + ? Math.round(sourcesSettings.precision) + : DEFAULT_PRECISION; + + await ensureDir(resolvedOutDir); + + const regions = parseShapefiles(parse, resolvedSourcesDir, precision); + await writeRegionModules(regions, dataTemplate, resolvedOutDir); +} + +interface ResolvedVectormap { + projectRoot: string; + resolvedSourceDir: string; + settingsFile: string; + sourcesDir: string; + sourcesSettingsFile: string; + resolvedUtilsOutDir: string; + dataOutDir: string; + resolvedUtilsTemplatePath: string; + resolvedDataTemplatePath: string; +} + +export default createExecutor({ + name: 'Vectormap', + resolve: (options, { projectRoot }) => { + return { + projectRoot, + resolvedSourceDir: path.resolve(projectRoot, options.sourceDir), + settingsFile: options.settingsFile, + sourcesDir: options.sourcesDir, + sourcesSettingsFile: options.sourcesSettingsFile, + resolvedUtilsOutDir: path.resolve(projectRoot, options.utilsOutDir), + dataOutDir: options.dataOutDir, + resolvedUtilsTemplatePath: path.resolve(projectRoot, options.utilsTemplatePath), + resolvedDataTemplatePath: path.resolve(projectRoot, options.dataTemplatePath), + }; + }, + run: async (resolved) => { + logger.verbose('Phase 1: Building vectormap utilities...'); + const { debugBundlePath } = await buildUtils( + resolved.resolvedSourceDir, + resolved.settingsFile, + resolved.resolvedUtilsTemplatePath, + resolved.resolvedUtilsOutDir, + ); + + logger.verbose('Phase 2: Building vectormap data...'); + await buildData( + debugBundlePath, + resolved.sourcesDir, + resolved.sourcesSettingsFile, + resolved.resolvedDataTemplatePath, + resolved.dataOutDir, + resolved.projectRoot, + ); + const dataFiles = fs + .readdirSync(path.resolve(resolved.projectRoot, resolved.dataOutDir)) + .filter((entry) => entry.endsWith('.js')); + logger.verbose(`Phase 2 complete: ${dataFiles.length} region modules produced`); + }, +}); diff --git a/packages/nx-infra-plugin/src/utils/concat-content.ts b/packages/nx-infra-plugin/src/utils/concat-content.ts deleted file mode 100644 index 57b71a4496f9..000000000000 --- a/packages/nx-infra-plugin/src/utils/concat-content.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { readFileText, writeFileText, normalizeEol } from './file-operations'; - -export interface ConcatOptions { - sourceFiles: string[]; - header?: string; - footer?: string; - extractPattern?: string; - extractPatternFlags?: string; - transforms?: { find: string; replace: string; flags?: string }[]; - normalizeLineEndings?: boolean; - separator?: string; -} - -function compileRegex(pattern: string, flags: string): RegExp { - try { - return new RegExp(pattern, flags); - } catch (error) { - throw new Error( - `Invalid regex pattern '${pattern}' (flags: '${flags}'): ${(error as Error).message}`, - ); - } -} - -function extractContent(content: string, pattern: string, flags: string): string { - const regex = compileRegex(pattern, flags); - const match = regex.exec(content); - return match?.[1] ?? content; -} - -function applyTransforms( - content: string, - transforms: { find: string; replace: string; flags?: string }[], -): string { - return transforms.reduce((result, { find, replace, flags = 'g' }) => { - return result.replace(compileRegex(find, flags), replace); - }, content); -} - -function applyHeaderFooter(content: string, header?: string, footer?: string): string { - let result = content; - if (header) result = header + result; - if (footer) result = result + footer; - return result; -} - -export async function concatFiles(opts: ConcatOptions): Promise { - const contents = await Promise.all( - opts.sourceFiles.map(async (filePath) => { - const content = await readFileText(filePath); - if (opts.extractPattern) { - return extractContent(content, opts.extractPattern, opts.extractPatternFlags ?? 'gm'); - } - return content; - }), - ); - - let output = contents.join(opts.separator ?? '\n'); - - if (opts.normalizeLineEndings !== false) { - output = normalizeEol(output); - } - - output = applyHeaderFooter(output, opts.header, opts.footer); - - if (opts.transforms?.length) { - output = applyTransforms(output, opts.transforms); - } - - return output; -} - -export async function concatToFile(outputFile: string, opts: ConcatOptions): Promise { - const content = await concatFiles(opts); - await writeFileText(outputFile, content); -} diff --git a/packages/nx-infra-plugin/src/utils/copy-directory.ts b/packages/nx-infra-plugin/src/utils/copy-directory.ts deleted file mode 100644 index 773e81518a5e..000000000000 --- a/packages/nx-infra-plugin/src/utils/copy-directory.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { glob } from 'glob'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { ensureDir } from './file-operations'; -import { toPosixPath } from './path-resolver'; - -export async function copyDirectory( - sourceDir: string, - destDir: string, - options: { include?: string[]; exclude?: string[] } = {}, -): Promise { - const includePatterns = options.include ?? ['**/*']; - const excludePatterns = options.exclude ?? []; - const cwd = toPosixPath(sourceDir); - - const relPaths = new Set(); - for (const pattern of includePatterns) { - const matches = await glob(pattern, { - cwd, - nodir: true, - ignore: excludePatterns, - }); - matches.forEach((m) => relPaths.add(m)); - } - - await Promise.all( - [...relPaths].map(async (relPath) => { - const src = path.join(sourceDir, relPath); - const dest = path.join(destDir, relPath); - await ensureDir(path.dirname(dest)); - await fs.copyFile(src, dest); - }), - ); -} diff --git a/packages/nx-infra-plugin/src/utils/create-executor.ts b/packages/nx-infra-plugin/src/utils/create-executor.ts new file mode 100644 index 000000000000..fd3526aaf96f --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/create-executor.ts @@ -0,0 +1,44 @@ +import { ExecutorContext, PromiseExecutor } from '@nx/devkit'; +import { resolveProjectPath } from './path-resolver'; +import { logError } from './error-handler'; + +const DEFAULT_ERROR_SUFFIX = 'executor failed'; + +export interface ExecutorRuntime { + projectRoot: string; + context: ExecutorContext; +} + +export type ResolveFn = ( + options: TOptions, + runtime: ExecutorRuntime, +) => TResolved | Promise; + +export type ImplementationFn = ( + resolved: TResolved, + options: TOptions, + runtime: ExecutorRuntime, +) => Promise; + +export interface ExecutorDefinition { + name: string; + resolve: ResolveFn; + run: ImplementationFn; +} + +export function createExecutor( + definition: ExecutorDefinition, +): PromiseExecutor { + return async (options, context) => { + const projectRoot = resolveProjectPath(context); + const runtime: ExecutorRuntime = { projectRoot, context }; + try { + const resolved = await definition.resolve(options, runtime); + await definition.run(resolved, options, runtime); + return { success: true }; + } catch (error) { + logError(`${definition.name} ${DEFAULT_ERROR_SUFFIX}`, error); + return { success: false }; + } + }; +} diff --git a/packages/nx-infra-plugin/src/utils/debug-strip.ts b/packages/nx-infra-plugin/src/utils/debug-strip.ts deleted file mode 100644 index 2100a38dccff..000000000000 --- a/packages/nx-infra-plugin/src/utils/debug-strip.ts +++ /dev/null @@ -1,5 +0,0 @@ -const REMOVE_DEBUG_REGEXP = /\/{2,}\s{0,}#DEBUG[\s\S]*?\/{2,}\s{0,}#ENDDEBUG/g; - -export function stripDebug(content: string): string { - return content.replace(REMOVE_DEBUG_REGEXP, ''); -} diff --git a/packages/nx-infra-plugin/src/utils/file-operations.ts b/packages/nx-infra-plugin/src/utils/file-operations.ts index cf3609a74168..033511466af0 100644 --- a/packages/nx-infra-plugin/src/utils/file-operations.ts +++ b/packages/nx-infra-plugin/src/utils/file-operations.ts @@ -3,8 +3,10 @@ import * as fse from 'fs-extra'; import * as path from 'path'; import * as os from 'os'; import { glob } from 'glob'; +import type { PackageJson } from './types'; const ENCODING_UTF8 = 'utf-8'; +const PACKAGE_JSON_FILENAME = 'package.json'; export async function ensureDir(dirPath: string): Promise { await fse.ensureDir(dirPath); @@ -73,3 +75,7 @@ export function normalizeEol(content: string): string { export function ensureTrailingNewline(content: string): string { return content.endsWith(os.EOL) ? content : content + os.EOL; } + +export async function loadProjectPackageJson(projectRoot: string): Promise { + return readJson(path.join(projectRoot, PACKAGE_JSON_FILENAME)); +} diff --git a/packages/nx-infra-plugin/src/utils/glob-discovery.ts b/packages/nx-infra-plugin/src/utils/glob-discovery.ts new file mode 100644 index 000000000000..211a10c00354 --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/glob-discovery.ts @@ -0,0 +1,63 @@ +import * as path from 'path'; +import { glob } from 'glob'; +import { minimatch } from 'minimatch'; +import { containsGlobPattern } from './common'; +import { toPosixPath } from './path-resolver'; + +export interface DiscoverFilesOptions { + cwd: string; + includePatterns: readonly string[]; + excludePatterns?: readonly string[]; + absolute?: boolean; + nodir?: boolean; +} + +export async function discoverFiles(options: DiscoverFilesOptions): Promise { + const cwd = toPosixPath(options.cwd); + const ignore = options.excludePatterns?.map(toPosixPath) as string[] | undefined; + const result = new Set(); + for (const pattern of options.includePatterns) { + const matches = await glob(pattern, { + cwd, + absolute: options.absolute ?? true, + nodir: options.nodir ?? true, + ignore, + }); + for (const file of matches) { + result.add(file); + } + } + return [...result]; +} + +export interface ExpandEntriesOptions { + projectRoot: string; + excludePatterns?: readonly string[]; +} + +function isPathExcludedByPatterns(absolutePath: string, patterns: string[]): boolean { + return patterns.some((pattern) => minimatch(toPosixPath(absolutePath), pattern, { dot: true })); +} + +export async function expandEntries( + entries: readonly string[], + options: ExpandEntriesOptions, +): Promise { + const ignorePatterns = options.excludePatterns?.map((pattern) => + toPosixPath(path.resolve(options.projectRoot, pattern)), + ); + + const result = new Set(); + for (const entry of entries) { + const absolute = path.resolve(options.projectRoot, entry); + if (containsGlobPattern(entry)) { + const matches = await glob(toPosixPath(absolute), { nodir: true, ignore: ignorePatterns }); + for (const file of matches) { + result.add(file); + } + } else if (!ignorePatterns || !isPathExcludedByPatterns(absolute, ignorePatterns)) { + result.add(absolute); + } + } + return [...result]; +} diff --git a/packages/nx-infra-plugin/src/utils/index.ts b/packages/nx-infra-plugin/src/utils/index.ts index 043a5f41eb09..5ea9b84eaedd 100644 --- a/packages/nx-infra-plugin/src/utils/index.ts +++ b/packages/nx-infra-plugin/src/utils/index.ts @@ -4,3 +4,5 @@ export * from './error-handler'; export * from './file-operations'; export * from './common'; export * from './test-utils'; +export * from './create-executor'; +export * from './glob-discovery'; diff --git a/packages/nx-infra-plugin/src/utils/path-resolver.ts b/packages/nx-infra-plugin/src/utils/path-resolver.ts index 8e166d2885da..3246c9d7156e 100644 --- a/packages/nx-infra-plugin/src/utils/path-resolver.ts +++ b/packages/nx-infra-plugin/src/utils/path-resolver.ts @@ -39,3 +39,19 @@ export function normalizeGlobPathForWindows(filePath: string): string { export function toPosixPath(absolutePath: string): string { return isWindowsOS() ? normalizeGlobPathForWindows(absolutePath) : absolutePath; } + +export function resolveOptionPaths, K extends keyof T>( + options: T, + projectRoot: string, + keys: readonly K[], + defaults?: Partial>, +): Record { + const out = {} as Record; + for (const key of keys) { + const raw = (options[key] as string | undefined) ?? defaults?.[key]; + if (raw !== undefined) { + out[key] = path.resolve(projectRoot, raw); + } + } + return out; +} From 7a7a078c89d6b014311dccd8541032bae3924303 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 4 May 2026 19:35:03 +0300 Subject: [PATCH 13/27] chore(devextreme): extract repeated input patterns to namedInputs --- packages/devextreme/project.json | 95 ++++++++++++++++---------------- 1 file changed, 46 insertions(+), 49 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index b868cb145143..a99e7a2be6d4 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -209,9 +209,7 @@ } }, "inputs": [ - "{projectRoot}/js/**/*.{js,jsx}", - "!{projectRoot}/js/**/*.d.ts", - "!{projectRoot}/js/__internal/**/*" + "jsSourcesProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled", @@ -233,8 +231,7 @@ ] }, "inputs": [ - "{projectRoot}/js/**/*.json", - "!{projectRoot}/js/__internal/**/*" + "jsAssetsProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled/**/*.json" @@ -255,8 +252,7 @@ ] }, "inputs": [ - "{projectRoot}/js/**/*.json", - "!{projectRoot}/js/__internal/**/*" + "jsAssetsProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled-renovation-npm/**/*.json" @@ -285,8 +281,7 @@ ] }, "inputs": [ - "{projectRoot}/js/**/*.json", - "!{projectRoot}/js/__internal/**/*" + "jsAssetsProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/**/*.json" @@ -306,9 +301,7 @@ "removeDebug": true }, "inputs": [ - "{projectRoot}/js/**/*.{js,jsx}", - "!{projectRoot}/js/**/*.d.ts", - "!{projectRoot}/js/__internal/**/*" + "jsSourcesProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/esm" @@ -328,9 +321,7 @@ "removeDebug": true }, "inputs": [ - "{projectRoot}/js/**/*.{js,jsx}", - "!{projectRoot}/js/**/*.d.ts", - "!{projectRoot}/js/__internal/**/*" + "jsSourcesProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/cjs" @@ -353,7 +344,7 @@ "removeDebug": true } }, - "inputs": ["{projectRoot}/artifacts/dist_ts/__internal/**/*.{js,jsx}"], + "inputs": ["internalTsArtifacts"], "outputs": [ "{projectRoot}/artifacts/transpiled/__internal", "{projectRoot}/artifacts/transpiled-renovation-npm/__internal" @@ -372,7 +363,7 @@ } }, "inputs": [ - "{projectRoot}/artifacts/dist_ts/__internal/**/*.{js,jsx}" + "internalTsArtifacts" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/esm/__internal" @@ -391,7 +382,7 @@ } }, "inputs": [ - "{projectRoot}/artifacts/dist_ts/__internal/**/*.{js,jsx}" + "internalTsArtifacts" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/cjs/__internal" @@ -553,12 +544,10 @@ } }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", "{projectRoot}/artifacts/transpiled-renovation-npm/bundles/**/*", "{projectRoot}/artifacts/transpiled-renovation-npm/**/*.js", - "{projectRoot}/webpack.config.js" + "webpackConfig" ], "outputs": [ "{projectRoot}/artifacts/js/dx.all.debug.js", @@ -615,11 +604,9 @@ } }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", "{projectRoot}/artifacts/transpiled-renovation-npm/**/*", - "{projectRoot}/webpack.config.js" + "webpackConfig" ], "outputs": [ "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration,custom}.debug.js" @@ -640,12 +627,10 @@ "webpackConfigPath": "./webpack.config.js" }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", "{projectRoot}/artifacts/transpiled-renovation-npm/bundles/**/*", "{projectRoot}/artifacts/transpiled-renovation-npm/**/*.js", - "{projectRoot}/webpack.config.js" + "webpackConfig" ], "outputs": [ "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" @@ -671,11 +656,9 @@ } }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", "{projectRoot}/artifacts/transpiled-renovation-npm/**/*", - "{projectRoot}/webpack.config.js" + "webpackConfig" ], "outputs": [ "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" @@ -974,8 +957,7 @@ ] }, "inputs": [ - "{workspaceRoot}/packages/devextreme-dist/README.md", - "{workspaceRoot}/packages/devextreme-dist/LICENSE.md" + "devextremeDistMeta" ], "outputs": [ "{projectRoot}/artifacts/npm/devextreme-dist/README.md", @@ -998,7 +980,7 @@ "{projectRoot}/js/**/*.json", "{projectRoot}/license/**/*", "{projectRoot}/build/npm-bin/**/*", - "{projectRoot}/webpack.config.js", + "webpackConfig", "{projectRoot}/artifacts/js/**/*", "{projectRoot}/artifacts/css/**/*", "{projectRoot}/artifacts/ts/**/*" @@ -1256,9 +1238,8 @@ "parallel": false }, "inputs": [ + "devextremeDistMeta", "{workspaceRoot}/packages/devextreme-dist/package.json", - "{workspaceRoot}/packages/devextreme-dist/README.md", - "{workspaceRoot}/packages/devextreme-dist/LICENSE.md", "{workspaceRoot}/packages/devextreme-scss/scss/**/*", "{workspaceRoot}/packages/devextreme-scss/fonts/**/*", "{workspaceRoot}/packages/devextreme-scss/icons/**/*", @@ -1272,7 +1253,7 @@ "{projectRoot}/build/gulp/modules_metadata.json", "{projectRoot}/ts/dx.all.d.ts", "{projectRoot}/ts/aliases.d.ts", - "{projectRoot}/webpack.config.js", + "webpackConfig", "{projectRoot}/package.json" ], "outputs": [ @@ -1312,9 +1293,7 @@ "parallel": false }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", "default", "test" ], @@ -1338,9 +1317,7 @@ "script": "build-dist" }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", "default", "test" ], @@ -1358,9 +1335,7 @@ "^build" ], "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", { "env": "DEVEXTREME_TEST_CI" }, @@ -1487,6 +1462,28 @@ "{projectRoot}/artifacts/js/dx.viz.debug.js", "{projectRoot}/artifacts/js/dx.ai-integration.debug.js", "{projectRoot}/artifacts/js/dx.custom.debug.js" + ], + "jsSourcesProduction": [ + "{projectRoot}/js/**/*.{js,jsx}", + "!{projectRoot}/js/**/*.d.ts", + "!{projectRoot}/js/__internal/**/*" + ], + "jsAssetsProduction": [ + "{projectRoot}/js/**/*.json", + "!{projectRoot}/js/__internal/**/*" + ], + "internalTsArtifacts": [ + "{projectRoot}/artifacts/dist_ts/__internal/**/*.{js,jsx}" + ], + "webpackConfig": [ + "{projectRoot}/webpack.config.js" + ], + "internalPackageEnv": [ + { "env": "BUILD_TEST_INTERNAL_PACKAGE" } + ], + "devextremeDistMeta": [ + "{workspaceRoot}/packages/devextreme-dist/README.md", + "{workspaceRoot}/packages/devextreme-dist/LICENSE.md" ] }, "tags": [] From f9511e26dedb28522286010d69d97097d0732e15 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 4 May 2026 19:43:26 +0300 Subject: [PATCH 14/27] chore(devextreme): consolidate variant targets into configurations --- packages/devextreme/project.json | 289 ++++++++++++++++--------------- 1 file changed, 154 insertions(+), 135 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index a99e7a2be6d4..4d934d099cc8 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -82,7 +82,7 @@ "{projectRoot}/js/__internal/core/localization/cldr-data" ] }, - "build:devextreme-bundler-config:generate": { + "build:devextreme-bundler-config": { "executor": "devextreme-nx-infra-plugin:concatenate-files", "options": { "sourceFiles": [ @@ -114,44 +114,45 @@ } ] }, - "inputs": [ - "{projectRoot}/build/bundle-templates/modules/parts/**/*.js" - ], - "outputs": [ - "{projectRoot}/build/bundle-templates/dx.custom.js" - ] - }, - "build:devextreme-bundler-config:prod": { - "dependsOn": [ - "build:devextreme-bundler-config:generate" - ], - "executor": "devextreme-nx-infra-plugin:concatenate-files", - "options": { - "sourceFiles": [ - "./build/bundle-templates/dx.custom.js" - ], - "outputFile": "./artifacts/npm/devextreme/bundles/dx.custom.config.js", - "transforms": [ - { - "find": "require *\\( *[\"']\\.\\.\\/", - "replace": "require('devextreme/" - } - ] + "configurations": { + "prod": { + "sourceFiles": [ + "./build/bundle-templates/dx.custom.js" + ], + "outputFile": "./artifacts/npm/devextreme/bundles/dx.custom.config.js", + "extractPattern": "", + "header": "", + "transforms": [ + { + "find": "require *\\( *[\"']\\.\\.\\/", + "replace": "require('devextreme/" + } + ] + }, + "prod-internal": { + "sourceFiles": [ + "./build/bundle-templates/dx.custom.js" + ], + "outputFile": "./artifacts/npm/devextreme-internal/bundles/dx.custom.config.js", + "extractPattern": "", + "header": "", + "transforms": [ + { + "find": "require *\\( *[\"']\\.\\.\\/", + "replace": "require('devextreme/" + } + ] + } }, "inputs": [ + "{projectRoot}/build/bundle-templates/modules/parts/**/*.js", "{projectRoot}/build/bundle-templates/dx.custom.js" ], "outputs": [ - "{projectRoot}/artifacts/npm/devextreme/bundles/dx.custom.config.js" - ], - "configurations": { - "internal": { - "outputFile": "./artifacts/npm/devextreme-internal/bundles/dx.custom.config.js", - "outputs": [ - "{projectRoot}/artifacts/npm/devextreme-internal/bundles/dx.custom.config.js" - ] - } - } + "{projectRoot}/build/bundle-templates/dx.custom.js", + "{projectRoot}/artifacts/npm/devextreme/bundles/dx.custom.config.js", + "{projectRoot}/artifacts/npm/devextreme-internal/bundles/dx.custom.config.js" + ] }, "clean:dist-ts": { "executor": "devextreme-nx-infra-plugin:clean", @@ -450,8 +451,8 @@ "executor": "nx:run-commands", "options": { "commands": [ - "pnpm nx build:devextreme-bundler-config:generate devextreme", - "pnpm nx build:devextreme-bundler-config:prod devextreme", + "pnpm nx build:devextreme-bundler-config devextreme", + "pnpm nx build:devextreme-bundler-config devextreme -c prod", "pnpm nx build:ts:internal devextreme", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel", "pnpm nx copy:json:transpiled devextreme", @@ -481,8 +482,8 @@ "configurations": { "ci": { "commands": [ - "pnpm nx build:devextreme-bundler-config:generate devextreme", - "pnpm nx build:devextreme-bundler-config:prod devextreme", + "pnpm nx build:devextreme-bundler-config devextreme", + "pnpm nx build:devextreme-bundler-config devextreme -c prod", "pnpm nx build:ts:internal devextreme", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel", "pnpm nx copy:json:transpiled devextreme", @@ -500,8 +501,8 @@ }, "internal": { "commands": [ - "pnpm nx build:devextreme-bundler-config:generate devextreme", - "pnpm nx build:devextreme-bundler-config:prod devextreme -c internal", + "pnpm nx build:devextreme-bundler-config devextreme", + "pnpm nx build:devextreme-bundler-config devextreme -c prod-internal", "pnpm nx build:ts:internal devextreme", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel", "pnpm nx copy:json:transpiled devextreme", @@ -523,7 +524,7 @@ } } }, - "bundle:debug:build": { + "bundle:build": { "executor": "devextreme-nx-infra-plugin:bundle", "options": { "entries": [ @@ -539,8 +540,35 @@ "webpackConfigPath": "./webpack.config.js" }, "configurations": { - "production": { + "debug": { + "entries": [ + "bundles/dx.all.js", + "bundles/dx.web.js", + "bundles/dx.viz.js", + "bundles/dx.ai-integration.js", + "bundles/dx.custom.js" + ], + "mode": "debug" + }, + "debug-production": { + "entries": [ + "bundles/dx.all.js", + "bundles/dx.web.js", + "bundles/dx.viz.js", + "bundles/dx.ai-integration.js", + "bundles/dx.custom.js" + ], + "mode": "debug", "sourceMap": false + }, + "prod": { + "entries": [ + "bundles/dx.ai-integration.js", + "bundles/dx.all.js", + "bundles/dx.web.js", + "bundles/dx.viz.js" + ], + "mode": "production" } }, "inputs": [ @@ -554,7 +582,8 @@ "{projectRoot}/artifacts/js/dx.web.debug.js", "{projectRoot}/artifacts/js/dx.viz.debug.js", "{projectRoot}/artifacts/js/dx.ai-integration.debug.js", - "{projectRoot}/artifacts/js/dx.custom.debug.js" + "{projectRoot}/artifacts/js/dx.custom.debug.js", + "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" ] }, "bundle:headers": { @@ -588,18 +617,18 @@ "executor": "nx:run-commands", "options": { "commands": [ - "pnpm nx bundle:debug:build devextreme", + "pnpm nx bundle:build devextreme -c debug", "pnpm nx bundle:headers devextreme", - "pnpm nx compress:bundles:debug devextreme" + "pnpm nx compress:bundles devextreme -c debug" ], "parallel": false }, "configurations": { "production": { "commands": [ - "pnpm nx bundle:debug:build devextreme -c production", + "pnpm nx bundle:build devextreme -c debug-production", "pnpm nx bundle:headers devextreme", - "pnpm nx compress:bundles:debug devextreme -c production" + "pnpm nx compress:bundles devextreme -c debug-production" ] } }, @@ -612,46 +641,22 @@ "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration,custom}.debug.js" ] }, - "bundle:prod:build": { - "executor": "devextreme-nx-infra-plugin:bundle", - "options": { - "entries": [ - "bundles/dx.ai-integration.js", - "bundles/dx.all.js", - "bundles/dx.web.js", - "bundles/dx.viz.js" - ], - "sourceDir": "./artifacts/transpiled-renovation-npm", - "outDir": "./artifacts/js", - "mode": "production", - "webpackConfigPath": "./webpack.config.js" - }, - "inputs": [ - "internalPackageEnv", - "{projectRoot}/artifacts/transpiled-renovation-npm/bundles/**/*", - "{projectRoot}/artifacts/transpiled-renovation-npm/**/*.js", - "webpackConfig" - ], - "outputs": [ - "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" - ] - }, "bundle:prod": { "executor": "nx:run-commands", "options": { "commands": [ - "pnpm nx bundle:prod:build devextreme", + "pnpm nx bundle:build devextreme -c prod", "pnpm nx bundle:headers devextreme -c prod", - "pnpm nx compress:bundles:prod devextreme" + "pnpm nx compress:bundles devextreme -c prod" ], "parallel": false }, "configurations": { "production": { "commands": [ - "pnpm nx bundle:prod:build devextreme", + "pnpm nx bundle:build devextreme -c prod", "pnpm nx bundle:headers devextreme -c prod", - "pnpm nx compress:bundles:prod devextreme -c production" + "pnpm nx compress:bundles devextreme -c prod-production" ] } }, @@ -664,33 +669,7 @@ "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" ] }, - "compress:bundles:prod": { - "executor": "devextreme-nx-infra-plugin:compress", - "options": { - "files": [ - "./artifacts/js/dx.all.js", - "./artifacts/js/dx.web.js", - "./artifacts/js/dx.viz.js", - "./artifacts/js/dx.ai-integration.js" - ], - "mode": { "name": "strip-debug", "trailingNewline": false } - }, - "configurations": { - "production": { - "mode": { - "name": "minify", - "eulaUrl": "https://js.devexpress.com/Licensing/" - } - } - }, - "inputs": [ - "prodBundles" - ], - "outputs": [ - "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" - ] - }, - "compress:bundles:debug": { + "compress:bundles": { "executor": "devextreme-nx-infra-plugin:compress", "options": { "files": [ @@ -703,18 +682,58 @@ "mode": { "name": "normalize", "trailingNewline": false } }, "configurations": { - "production": { + "debug": { + "files": [ + "./artifacts/js/dx.all.debug.js", + "./artifacts/js/dx.web.debug.js", + "./artifacts/js/dx.viz.debug.js", + "./artifacts/js/dx.ai-integration.debug.js", + "./artifacts/js/dx.custom.debug.js" + ], + "mode": { "name": "normalize", "trailingNewline": false } + }, + "debug-production": { + "files": [ + "./artifacts/js/dx.all.debug.js", + "./artifacts/js/dx.web.debug.js", + "./artifacts/js/dx.viz.debug.js", + "./artifacts/js/dx.ai-integration.debug.js", + "./artifacts/js/dx.custom.debug.js" + ], "mode": { "name": "beautify", "eulaUrl": "https://js.devexpress.com/Licensing/" } + }, + "prod": { + "files": [ + "./artifacts/js/dx.all.js", + "./artifacts/js/dx.web.js", + "./artifacts/js/dx.viz.js", + "./artifacts/js/dx.ai-integration.js" + ], + "mode": { "name": "strip-debug", "trailingNewline": false } + }, + "prod-production": { + "files": [ + "./artifacts/js/dx.all.js", + "./artifacts/js/dx.web.js", + "./artifacts/js/dx.viz.js", + "./artifacts/js/dx.ai-integration.js" + ], + "mode": { + "name": "minify", + "eulaUrl": "https://js.devexpress.com/Licensing/" + } } }, "inputs": [ - "debugBundles" + "debugBundles", + "prodBundles" ], "outputs": [ - "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration,custom}.debug.js" + "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration,custom}.debug.js", + "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" ] }, "build:vectormap:generate": { @@ -754,31 +773,33 @@ "{projectRoot}/artifacts/js/vectormap-utils" ] }, - "compress:vectormap:strip-debug": { + "compress:vectormap": { "executor": "devextreme-nx-infra-plugin:compress", "options": { "files": [ "./artifacts/js/vectormap-utils/dx.vectormaputils.js" ], "mode": "strip-debug" - } - }, - "compress:vectormap:beautify": { - "executor": "devextreme-nx-infra-plugin:compress", - "options": { - "files": [ - "./artifacts/js/vectormap-utils/dx.vectormaputils.debug.js" - ], - "mode": { "name": "beautify", "eulaUrl": "https://js.devexpress.com/Licensing/" } - } - }, - "compress:vectormap:minify": { - "executor": "devextreme-nx-infra-plugin:compress", - "options": { - "files": [ - "./artifacts/js/vectormap-utils/dx.vectormaputils.js" - ], - "mode": { "name": "minify", "eulaUrl": "https://js.devexpress.com/Licensing/" } + }, + "configurations": { + "strip-debug": { + "files": [ + "./artifacts/js/vectormap-utils/dx.vectormaputils.js" + ], + "mode": "strip-debug" + }, + "beautify": { + "files": [ + "./artifacts/js/vectormap-utils/dx.vectormaputils.debug.js" + ], + "mode": { "name": "beautify", "eulaUrl": "https://js.devexpress.com/Licensing/" } + }, + "minify": { + "files": [ + "./artifacts/js/vectormap-utils/dx.vectormaputils.js" + ], + "mode": { "name": "minify", "eulaUrl": "https://js.devexpress.com/Licensing/" } + } } }, "build:vectormap": { @@ -787,7 +808,7 @@ "commands": [ "pnpm nx build:vectormap:generate devextreme", "pnpm nx build:vectormap:headers devextreme", - "pnpm nx compress:vectormap:strip-debug devextreme" + "pnpm nx compress:vectormap devextreme -c strip-debug" ], "parallel": false }, @@ -806,8 +827,8 @@ "commands": [ "pnpm nx build:vectormap:generate devextreme", "pnpm nx build:vectormap:headers devextreme", - "pnpm nx compress:vectormap:beautify devextreme", - "pnpm nx compress:vectormap:minify devextreme" + "pnpm nx compress:vectormap devextreme -c beautify", + "pnpm nx compress:vectormap devextreme -c minify" ] } } @@ -822,18 +843,16 @@ "inputs": ["{projectRoot}/js/aspnet.js"], "outputs": ["{projectRoot}/artifacts/js/dx.aspnet.mvc.js"] }, - "compress:aspnet:normalize": { - "executor": "devextreme-nx-infra-plugin:compress", - "options": { - "files": ["./artifacts/js/dx.aspnet.mvc.js"], - "mode": "normalize" - } - }, "compress:aspnet": { "executor": "devextreme-nx-infra-plugin:compress", "options": { "files": ["./artifacts/js/dx.aspnet.mvc.js"], "mode": { "name": "beautify", "eulaUrl": "https://js.devexpress.com/Licensing/" } + }, + "configurations": { + "normalize": { + "mode": "normalize" + } } }, "build:aspnet:headers": { @@ -853,7 +872,7 @@ "options": { "commands": [ "pnpm nx build:aspnet:copy devextreme", - "pnpm nx compress:aspnet:normalize devextreme", + "pnpm nx compress:aspnet devextreme -c normalize", "pnpm nx build:aspnet:headers devextreme" ], "parallel": false From e9341f0d49d2176727b032221e2b50be5467e9a7 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 4 May 2026 20:08:36 +0300 Subject: [PATCH 15/27] chore(nx-infra-plugin): absorb wrapper targets into producer executors --- packages/devextreme/project.json | 312 +++++------------- .../babel-transform/babel-transform.impl.ts | 54 ++- .../babel-transform/executor.e2e.spec.ts | 101 ++++++ .../src/executors/babel-transform/schema.json | 18 + .../src/executors/babel-transform/schema.ts | 6 + .../src/executors/bundle/bundle.impl.ts | 56 +++- .../src/executors/bundle/executor.e2e.spec.ts | 36 +- .../src/executors/bundle/schema.json | 52 +++ .../src/executors/bundle/schema.ts | 14 + .../executors/copy-files/copy-files.impl.ts | 45 ++- .../executors/copy-files/executor.e2e.spec.ts | 33 ++ .../src/executors/copy-files/schema.json | 18 + .../src/executors/copy-files/schema.ts | 15 + .../localization/executor.e2e.spec.ts | 27 ++ .../localization/localization.impl.ts | 48 ++- .../src/executors/localization/schema.json | 17 + .../src/executors/localization/schema.ts | 15 + .../npm-assemble/executor.e2e.spec.ts | 97 ++++++ .../npm-assemble/npm-assemble.impl.ts | 71 +++- .../src/executors/npm-assemble/schema.json | 36 ++ .../src/executors/npm-assemble/schema.ts | 12 + .../executors/vectormap/executor.e2e.spec.ts | 33 ++ .../src/executors/vectormap/schema.json | 17 + .../src/executors/vectormap/schema.ts | 15 + .../src/executors/vectormap/vectormap.impl.ts | 42 ++- 25 files changed, 945 insertions(+), 245 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 4d934d099cc8..caf4b43a792b 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -31,7 +31,12 @@ "messageOutputDir": "./artifacts/js/localization", "generatedTemplate": "./build/gulp/generated_js.jst", "cldrDataOutputDir": "./js/__internal/core/localization/cldr-data", - "defaultMessagesOutputDir": "./js/__internal/core/localization" + "defaultMessagesOutputDir": "./js/__internal/core/localization", + "applyLicenseHeaders": { + "separator": "", + "prependAfterLicense": "\"use strict\";\n\n", + "includePatterns": ["**/*.js"] + } }, "inputs": [ "{projectRoot}/js/localization/messages/**/*.json", @@ -44,30 +49,12 @@ "{projectRoot}/js/__internal/core/localization/cldr-data" ] }, - "build:localization:headers": { - "executor": "devextreme-nx-infra-plugin:add-license-headers", - "options": { - "targetDirectory": "./artifacts/js/localization", - "prependAfterLicense": "\"use strict\";\n\n", - "separatorBetweenBannerAndContent": "", - "includePatterns": [ - "**/*.js" - ] - }, - "inputs": [ - "{projectRoot}/artifacts/js/localization/**/*.js" - ], - "outputs": [ - "{projectRoot}/artifacts/js/localization" - ] - }, "build:localization": { "executor": "nx:run-commands", "options": { "commands": [ "pnpm nx clean:cldr-data devextreme", - "pnpm nx build:localization:generate devextreme", - "pnpm nx build:localization:headers devextreme" + "pnpm nx build:localization:generate devextreme" ], "parallel": false }, @@ -201,7 +188,11 @@ "./js/**/*.d.ts", "./js/__internal/**/*" ], - "outDir": "./artifacts/transpiled" + "outDir": "./artifacts/transpiled", + "copyAssets": [ + { "from": "./js/localization/messages", "to": "./localization/messages" }, + { "from": "./js/viz/vector_map.utils/_settings.json", "to": "./viz/vector_map.utils/_settings.json" } + ] }, "configurations": { "production": { @@ -210,84 +201,14 @@ } }, "inputs": [ - "jsSourcesProduction" + "jsSourcesProduction", + "jsAssetsProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled", "{projectRoot}/artifacts/transpiled-renovation-npm" ] }, - "copy:json:transpiled": { - "executor": "devextreme-nx-infra-plugin:copy-files", - "options": { - "files": [ - { - "from": "./js/localization/messages", - "to": "./artifacts/transpiled/localization/messages" - }, - { - "from": "./js/viz/vector_map.utils/_settings.json", - "to": "./artifacts/transpiled/viz/vector_map.utils/_settings.json" - } - ] - }, - "inputs": [ - "jsAssetsProduction" - ], - "outputs": [ - "{projectRoot}/artifacts/transpiled/**/*.json" - ] - }, - "copy:json:transpiled-production": { - "executor": "devextreme-nx-infra-plugin:copy-files", - "options": { - "files": [ - { - "from": "./js/localization/messages", - "to": "./artifacts/transpiled-renovation-npm/localization/messages" - }, - { - "from": "./js/viz/vector_map.utils/_settings.json", - "to": "./artifacts/transpiled-renovation-npm/viz/vector_map.utils/_settings.json" - } - ] - }, - "inputs": [ - "jsAssetsProduction" - ], - "outputs": [ - "{projectRoot}/artifacts/transpiled-renovation-npm/**/*.json" - ] - }, - "copy:json:esm-npm": { - "executor": "devextreme-nx-infra-plugin:copy-files", - "options": { - "files": [ - { - "from": "./js/localization/messages", - "to": "./artifacts/transpiled-esm-npm/esm/localization/messages" - }, - { - "from": "./js/viz/vector_map.utils/_settings.json", - "to": "./artifacts/transpiled-esm-npm/esm/viz/vector_map.utils/_settings.json" - }, - { - "from": "./js/localization/messages", - "to": "./artifacts/transpiled-esm-npm/cjs/localization/messages" - }, - { - "from": "./js/viz/vector_map.utils/_settings.json", - "to": "./artifacts/transpiled-esm-npm/cjs/viz/vector_map.utils/_settings.json" - } - ] - }, - "inputs": [ - "jsAssetsProduction" - ], - "outputs": [ - "{projectRoot}/artifacts/transpiled-esm-npm/**/*.json" - ] - }, "build:npm:esm": { "executor": "devextreme-nx-infra-plugin:babel-transform", "options": { @@ -299,10 +220,15 @@ "./js/__internal/**/*" ], "outDir": "./artifacts/transpiled-esm-npm/esm", - "removeDebug": true + "removeDebug": true, + "copyAssets": [ + { "from": "./js/localization/messages", "to": "./localization/messages" }, + { "from": "./js/viz/vector_map.utils/_settings.json", "to": "./viz/vector_map.utils/_settings.json" } + ] }, "inputs": [ - "jsSourcesProduction" + "jsSourcesProduction", + "jsAssetsProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/esm" @@ -319,10 +245,15 @@ "./js/__internal/**/*" ], "outDir": "./artifacts/transpiled-esm-npm/cjs", - "removeDebug": true + "removeDebug": true, + "copyAssets": [ + { "from": "./js/localization/messages", "to": "./localization/messages" }, + { "from": "./js/viz/vector_map.utils/_settings.json", "to": "./viz/vector_map.utils/_settings.json" } + ] }, "inputs": [ - "jsSourcesProduction" + "jsSourcesProduction", + "jsAssetsProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/cjs" @@ -455,11 +386,8 @@ "pnpm nx build:devextreme-bundler-config devextreme -c prod", "pnpm nx build:ts:internal devextreme", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel", - "pnpm nx copy:json:transpiled devextreme", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel -c production", - "pnpm nx copy:json:transpiled-production devextreme", "pnpm nx run-many --targets=build:npm:esm,build:npm:esm:internal,build:npm:cjs,build:npm:cjs:internal --projects=devextreme --parallel", - "pnpm nx copy:json:esm-npm devextreme", "pnpm nx build:cjs:bundles devextreme -c esm-npm", "pnpm nx build:npm:dual-mode devextreme", "pnpm nx clean:dist-ts devextreme" @@ -470,6 +398,7 @@ "inputs": [ "{projectRoot}/js/**/*.{js,ts,tsx}", "!{projectRoot}/js/**/*.d.ts", + "{projectRoot}/js/**/*.json", "{projectRoot}/build/bundle-templates/**/*.js" ], "outputs": [ @@ -486,9 +415,7 @@ "pnpm nx build:devextreme-bundler-config devextreme -c prod", "pnpm nx build:ts:internal devextreme", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel", - "pnpm nx copy:json:transpiled devextreme", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel -c production", - "pnpm nx copy:json:transpiled-production devextreme", "pnpm nx run-many --targets=build:npm:cjs,build:npm:cjs:internal --projects=devextreme --parallel", "pnpm nx clean:dist-ts devextreme" ], @@ -505,11 +432,8 @@ "pnpm nx build:devextreme-bundler-config devextreme -c prod-internal", "pnpm nx build:ts:internal devextreme", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel", - "pnpm nx copy:json:transpiled devextreme", "pnpm nx run-many --targets=build:cjs,build:cjs:internal,build:cjs:bundles --projects=devextreme --parallel -c production", - "pnpm nx copy:json:transpiled-production devextreme", "pnpm nx run-many --targets=build:npm:esm,build:npm:esm:internal,build:npm:cjs,build:npm:cjs:internal --projects=devextreme --parallel", - "pnpm nx copy:json:esm-npm devextreme", "pnpm nx build:cjs:bundles devextreme -c esm-npm", "pnpm nx build:npm:dual-mode devextreme", "pnpm nx clean:dist-ts devextreme" @@ -548,7 +472,12 @@ "bundles/dx.ai-integration.js", "bundles/dx.custom.js" ], - "mode": "debug" + "mode": "debug", + "applyLicenseHeaders": { + "prependAfterLicense": "\"use strict\";\n\n", + "separator": "", + "includePatterns": ["dx.*.debug.js"] + } }, "debug-production": { "entries": [ @@ -559,7 +488,12 @@ "bundles/dx.custom.js" ], "mode": "debug", - "sourceMap": false + "sourceMap": false, + "applyLicenseHeaders": { + "prependAfterLicense": "\"use strict\";\n\n", + "separator": "", + "includePatterns": ["dx.*.debug.js"] + } }, "prod": { "entries": [ @@ -568,7 +502,12 @@ "bundles/dx.web.js", "bundles/dx.viz.js" ], - "mode": "production" + "mode": "production", + "applyLicenseHeaders": { + "prependAfterLicense": "\"use strict\";\n\n", + "separator": "", + "includePatterns": ["dx.all.js", "dx.web.js", "dx.viz.js", "dx.ai-integration.js"] + } } }, "inputs": [ @@ -586,39 +525,11 @@ "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" ] }, - "bundle:headers": { - "executor": "devextreme-nx-infra-plugin:add-license-headers", - "options": { - "targetDirectory": "./artifacts/js", - "prependAfterLicense": "\"use strict\";\n\n", - "separatorBetweenBannerAndContent": "", - "includePatterns": [ - "dx.*.debug.js" - ] - }, - "configurations": { - "prod": { - "includePatterns": [ - "dx.all.js", - "dx.web.js", - "dx.viz.js", - "dx.ai-integration.js" - ] - } - }, - "inputs": [ - "{projectRoot}/artifacts/js/dx.*.js" - ], - "outputs": [ - "{projectRoot}/artifacts/js/dx.*.js" - ] - }, "bundle:debug": { "executor": "nx:run-commands", "options": { "commands": [ "pnpm nx bundle:build devextreme -c debug", - "pnpm nx bundle:headers devextreme", "pnpm nx compress:bundles devextreme -c debug" ], "parallel": false @@ -627,7 +538,6 @@ "production": { "commands": [ "pnpm nx bundle:build devextreme -c debug-production", - "pnpm nx bundle:headers devextreme", "pnpm nx compress:bundles devextreme -c debug-production" ] } @@ -646,7 +556,6 @@ "options": { "commands": [ "pnpm nx bundle:build devextreme -c prod", - "pnpm nx bundle:headers devextreme -c prod", "pnpm nx compress:bundles devextreme -c prod" ], "parallel": false @@ -655,7 +564,6 @@ "production": { "commands": [ "pnpm nx bundle:build devextreme -c prod", - "pnpm nx bundle:headers devextreme -c prod", "pnpm nx compress:bundles devextreme -c prod-production" ] } @@ -746,7 +654,11 @@ "utilsOutDir": "./artifacts/js/vectormap-utils", "dataOutDir": "./artifacts/js/vectormap-data", "utilsTemplatePath": "./build/gulp/vectormaputils-template.jst", - "dataTemplatePath": "./build/gulp/vectormapdata-template.jst" + "dataTemplatePath": "./build/gulp/vectormapdata-template.jst", + "applyLicenseHeaders": { + "separator": "", + "prependAfterLicense": "\"use strict\";\n\n" + } }, "inputs": [ "{projectRoot}/js/viz/vector_map.utils/**/*", @@ -759,20 +671,6 @@ "{projectRoot}/artifacts/js/vectormap-data" ] }, - "build:vectormap:headers": { - "executor": "devextreme-nx-infra-plugin:add-license-headers", - "options": { - "targetDirectory": "./artifacts/js/vectormap-utils", - "separatorBetweenBannerAndContent": "", - "prependAfterLicense": "\"use strict\";\n\n" - }, - "inputs": [ - "{projectRoot}/artifacts/js/vectormap-utils/**/*" - ], - "outputs": [ - "{projectRoot}/artifacts/js/vectormap-utils" - ] - }, "compress:vectormap": { "executor": "devextreme-nx-infra-plugin:compress", "options": { @@ -807,7 +705,6 @@ "options": { "commands": [ "pnpm nx build:vectormap:generate devextreme", - "pnpm nx build:vectormap:headers devextreme", "pnpm nx compress:vectormap devextreme -c strip-debug" ], "parallel": false @@ -826,7 +723,6 @@ "production": { "commands": [ "pnpm nx build:vectormap:generate devextreme", - "pnpm nx build:vectormap:headers devextreme", "pnpm nx compress:vectormap devextreme -c beautify", "pnpm nx compress:vectormap devextreme -c minify" ] @@ -838,7 +734,12 @@ "options": { "files": [ { "from": "./js/aspnet.js", "to": "./artifacts/js/dx.aspnet.mvc.js" } - ] + ], + "applyLicenseHeaders": { + "targetSubdir": "./artifacts/js", + "separator": "", + "includePatterns": ["dx.aspnet.mvc.js"] + } }, "inputs": ["{projectRoot}/js/aspnet.js"], "outputs": ["{projectRoot}/artifacts/js/dx.aspnet.mvc.js"] @@ -855,25 +756,12 @@ } } }, - "build:aspnet:headers": { - "executor": "devextreme-nx-infra-plugin:add-license-headers", - "options": { - "targetDirectory": "./artifacts/js", - "includePatterns": ["dx.aspnet.mvc.js"], - "separatorBetweenBannerAndContent": "" - }, - "inputs": [ - "{projectRoot}/artifacts/js/dx.aspnet.mvc.js" - ], - "outputs": ["{projectRoot}/artifacts/js/dx.aspnet.mvc.js"] - }, "build:aspnet": { "executor": "nx:run-commands", "options": { "commands": [ "pnpm nx build:aspnet:copy devextreme", - "pnpm nx compress:aspnet devextreme -c normalize", - "pnpm nx build:aspnet:headers devextreme" + "pnpm nx compress:aspnet devextreme -c normalize" ], "parallel": false }, @@ -881,8 +769,7 @@ "production": { "commands": [ "pnpm nx build:aspnet:copy devextreme", - "pnpm nx compress:aspnet devextreme", - "pnpm nx build:aspnet:headers devextreme" + "pnpm nx compress:aspnet devextreme" ] } }, @@ -961,28 +848,6 @@ ], "outputs": ["{projectRoot}/artifacts/npm/devextreme-dist/package.json"] }, - "build:npm:dist:meta": { - "executor": "devextreme-nx-infra-plugin:copy-files", - "options": { - "files": [ - { - "from": "../devextreme-dist/README.md", - "to": "./artifacts/npm/devextreme-dist/README.md" - }, - { - "from": "../devextreme-dist/LICENSE.md", - "to": "./artifacts/npm/devextreme-dist/LICENSE.md" - } - ] - }, - "inputs": [ - "devextremeDistMeta" - ], - "outputs": [ - "{projectRoot}/artifacts/npm/devextreme-dist/README.md", - "{projectRoot}/artifacts/npm/devextreme-dist/LICENSE.md" - ] - }, "build:npm:assemble": { "executor": "devextreme-nx-infra-plugin:npm-assemble", "options": { @@ -992,20 +857,35 @@ "npmBinDir": "./build/npm-bin", "webpackConfig": "./webpack.config.js", "artifactsDir": "./artifacts", - "outputDir": "./artifacts/npm/devextreme" + "outputDir": "./artifacts/npm/devextreme", + "metadataFiles": [ + { "from": "../../README.md", "to": "./README.md" }, + { "from": "./build/npm-templates/.npmignore", "to": "./.npmignore" }, + { "from": "../devextreme-dist/README.md", "to": "../devextreme-dist/README.md" }, + { "from": "../devextreme-dist/LICENSE.md", "to": "../devextreme-dist/LICENSE.md" } + ], + "flatten": [ + { "from": "./dist", "to": "./artifacts/npm/devextreme-dist" } + ] }, "inputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/**/*", "{projectRoot}/js/**/*.json", "{projectRoot}/license/**/*", "{projectRoot}/build/npm-bin/**/*", + "{projectRoot}/build/npm-templates/.npmignore", "webpackConfig", "{projectRoot}/artifacts/js/**/*", "{projectRoot}/artifacts/css/**/*", - "{projectRoot}/artifacts/ts/**/*" + "{projectRoot}/artifacts/ts/**/*", + "{workspaceRoot}/README.md", + "devextremeDistMeta" ], "outputs": [ - "{projectRoot}/artifacts/npm/devextreme/**/*" + "{projectRoot}/artifacts/npm/devextreme/**/*", + "{projectRoot}/artifacts/npm/devextreme-dist/README.md", + "{projectRoot}/artifacts/npm/devextreme-dist/LICENSE.md", + "{projectRoot}/artifacts/npm/devextreme-dist/**/*" ] }, "build:npm:scss": { @@ -1036,23 +916,6 @@ ], "outputs": ["{projectRoot}/artifacts/npm/devextreme/package.json"] }, - "build:npm:meta": { - "executor": "devextreme-nx-infra-plugin:copy-files", - "options": { - "files": [ - { "from": "../../README.md", "to": "./artifacts/npm/devextreme/README.md" }, - { "from": "./build/npm-templates/.npmignore", "to": "./artifacts/npm/devextreme/.npmignore" } - ] - }, - "inputs": [ - "{workspaceRoot}/README.md", - "{projectRoot}/build/npm-templates/.npmignore" - ], - "outputs": [ - "{projectRoot}/artifacts/npm/devextreme/README.md", - "{projectRoot}/artifacts/npm/devextreme/.npmignore" - ] - }, "compress:npm-sources": { "executor": "devextreme-nx-infra-plugin:compress", "options": { @@ -1214,19 +1077,6 @@ "cwd": "{projectRoot}/artifacts/npm/devextreme-dist" } }, - "build:npm:dist-flatten": { - "executor": "devextreme-nx-infra-plugin:copy-files", - "options": { - "files": [ - { - "from": "./artifacts/npm/devextreme/dist", - "to": "./artifacts/npm/devextreme-dist" - } - ] - }, - "inputs": ["{projectRoot}/artifacts/npm/devextreme/dist/**/*"], - "outputs": ["{projectRoot}/artifacts/npm/devextreme-dist/**/*"] - }, "verify:public-modules": { "executor": "nx:run-commands", "options": { @@ -1245,12 +1095,9 @@ "pnpm nx run devextreme:build:npm:dts-modules", "pnpm nx run devextreme:build:npm:dts-bundle", "pnpm nx run devextreme:build:npm:dist:package-json", - "pnpm nx run devextreme:build:npm:dist:meta", "pnpm nx run devextreme:build:npm:assemble", "pnpm nx run devextreme:build:npm:root-package-json", - "pnpm nx run devextreme:build:npm:meta", "pnpm nx run devextreme:compress:npm-sources", - "pnpm nx run devextreme:build:npm:dist-flatten", "pnpm nx run devextreme:verify:public-modules", "pnpm nx run devextreme:build:npm:scss" ], @@ -1286,12 +1133,9 @@ "pnpm nx run devextreme:build:npm:dts-modules", "pnpm nx run devextreme:build:npm:dts-bundle", "pnpm nx run devextreme:build:npm:dist:package-json", - "pnpm nx run devextreme:build:npm:dist:meta", "pnpm nx run devextreme:build:npm:assemble", "pnpm nx run devextreme:build:npm:root-package-json", - "pnpm nx run devextreme:build:npm:meta", "pnpm nx run devextreme:compress:npm-sources -c production", - "pnpm nx run devextreme:build:npm:dist-flatten", "pnpm nx run devextreme:verify:public-modules", "pnpm nx run devextreme:build:npm:scss" ] diff --git a/packages/nx-infra-plugin/src/executors/babel-transform/babel-transform.impl.ts b/packages/nx-infra-plugin/src/executors/babel-transform/babel-transform.impl.ts index c3efe15af52f..588c25184816 100644 --- a/packages/nx-infra-plugin/src/executors/babel-transform/babel-transform.impl.ts +++ b/packages/nx-infra-plugin/src/executors/babel-transform/babel-transform.impl.ts @@ -1,14 +1,18 @@ import * as path from 'path'; import * as fs from 'fs-extra'; +import { stat } from 'fs/promises'; import * as babel from '@babel/core'; import { glob } from 'glob'; import { logger } from '@nx/devkit'; import { createExecutor } from '../../utils/create-executor'; import { toPosixPath } from '../../utils/path-resolver'; +import { copyFile } from '../../utils/file-operations'; +import { copyDirectory } from '../copy-files/copy-files.impl'; import { stripDebug } from '../compress/compress.impl'; -import { BabelTransformExecutorSchema } from './schema'; +import { BabelTransformAsset, BabelTransformExecutorSchema } from './schema'; const ERROR_NO_FILES_MATCHED = 'No files matched the source pattern'; +const ERROR_ASSET_NOT_FOUND = (source: string) => `Asset source not found: ${source}`; function loadBabelConfig( projectRoot: string, @@ -81,6 +85,29 @@ async function transformFile( await fs.writeFile(outputPath, result.code); } +interface ResolvedBabelTransformAsset { + source: string; + destination: string; +} + +async function copyResolvedAsset(asset: ResolvedBabelTransformAsset): Promise { + let assetStat; + try { + assetStat = await stat(asset.source); + } catch { + throw new Error(ERROR_ASSET_NOT_FOUND(asset.source)); + } + + if (assetStat.isDirectory()) { + await copyDirectory(asset.source, asset.destination); + logger.verbose(`Copied asset directory ${asset.source} -> ${asset.destination}`); + return; + } + + await copyFile(asset.source, asset.destination); + logger.verbose(`Copied asset file ${asset.source} -> ${asset.destination}`); +} + interface ResolvedBabelTransform { projectRoot: string; babelConfig: babel.TransformOptions; @@ -88,6 +115,19 @@ interface ResolvedBabelTransform { renameExtensions: Record; globPattern: string; excludePatterns: string[]; + resolvedAssets: ResolvedBabelTransformAsset[]; +} + +function resolveAssets( + assets: BabelTransformAsset[], + projectRoot: string, + outDir: string, +): ResolvedBabelTransformAsset[] { + const outDirAbsolute = path.isAbsolute(outDir) ? outDir : path.join(projectRoot, outDir); + return assets.map((asset) => ({ + source: path.isAbsolute(asset.from) ? asset.from : path.join(projectRoot, asset.from), + destination: path.isAbsolute(asset.to) ? asset.to : path.join(outDirAbsolute, asset.to), + })); } export default createExecutor({ @@ -106,6 +146,8 @@ export default createExecutor { @@ -148,5 +191,14 @@ export default createExecutor 0) { + logger.verbose( + `Copying ${resolved.resolvedAssets.length} asset entries to ${options.outDir}`, + ); + for (const asset of resolved.resolvedAssets) { + await copyResolvedAsset(asset); + } + } }, }); diff --git a/packages/nx-infra-plugin/src/executors/babel-transform/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/babel-transform/executor.e2e.spec.ts index bf35f0bd6dcb..36186b5a2de4 100644 --- a/packages/nx-infra-plugin/src/executors/babel-transform/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/babel-transform/executor.e2e.spec.ts @@ -161,4 +161,105 @@ export function helper() { expect(utilsContent).not.toContain('This is debug code'); }, 30000); + + describe('copyAssets option', () => { + const MINIMAL_BABEL_CONFIG = ` +'use strict'; +module.exports = { + cjs: { + plugins: [['@babel/plugin-transform-modules-commonjs', { strict: true }]], + }, +}; +`; + const minimalConfigPath = './build/gulp/minimal-config.js'; + + beforeEach(async () => { + await writeFileText(path.join(projectDir, minimalConfigPath), MINIMAL_BABEL_CONFIG); + }); + + it('should not copy any extra files when copyAssets is omitted', async () => { + const options: BabelTransformExecutorSchema = { + babelConfigPath: minimalConfigPath, + configKey: 'cjs', + sourcePattern: './js/**/*.js', + outDir: './artifacts/transpiled-no-assets', + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const outputDir = path.join(projectDir, 'artifacts', 'transpiled-no-assets'); + const entries = fs.readdirSync(outputDir).sort(); + + expect(entries).toEqual(['module.js', 'utils.js']); + }, 30000); + + it('should copy a single asset file into outDir', async () => { + const settingsPath = path.join(projectDir, 'js', 'viz', 'vector_map.utils', '_settings.json'); + await writeFileText(settingsPath, JSON.stringify({ scale: 1 })); + + const options: BabelTransformExecutorSchema = { + babelConfigPath: minimalConfigPath, + configKey: 'cjs', + sourcePattern: './js/**/*.js', + outDir: './artifacts/transpiled-with-file-asset', + copyAssets: [ + { + from: './js/viz/vector_map.utils/_settings.json', + to: './viz/vector_map.utils/_settings.json', + }, + ], + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const outputDir = path.join(projectDir, 'artifacts', 'transpiled-with-file-asset'); + const copiedSettingsPath = path.join(outputDir, 'viz', 'vector_map.utils', '_settings.json'); + + expect(fs.existsSync(copiedSettingsPath)).toBe(true); + const copiedSettings = JSON.parse(await readFileText(copiedSettingsPath)); + expect(copiedSettings).toEqual({ scale: 1 }); + + expect(fs.existsSync(path.join(outputDir, 'module.js'))).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'utils.js'))).toBe(true); + }, 30000); + + it('should copy a directory of assets recursively into outDir', async () => { + const messagesDir = path.join(projectDir, 'js', 'localization', 'messages'); + fs.mkdirSync(messagesDir, { recursive: true }); + await writeFileText(path.join(messagesDir, 'en.json'), JSON.stringify({ greeting: 'Hello' })); + await writeFileText(path.join(messagesDir, 'de.json'), JSON.stringify({ greeting: 'Hallo' })); + + const nestedDir = path.join(messagesDir, 'extra'); + fs.mkdirSync(nestedDir, { recursive: true }); + await writeFileText(path.join(nestedDir, 'fr.json'), JSON.stringify({ greeting: 'Bonjour' })); + + const options: BabelTransformExecutorSchema = { + babelConfigPath: minimalConfigPath, + configKey: 'cjs', + sourcePattern: './js/**/*.js', + outDir: './artifacts/transpiled-with-dir-asset', + copyAssets: [ + { + from: './js/localization/messages', + to: './localization/messages', + }, + ], + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const outputDir = path.join(projectDir, 'artifacts', 'transpiled-with-dir-asset'); + const copiedMessagesDir = path.join(outputDir, 'localization', 'messages'); + + expect(fs.existsSync(path.join(copiedMessagesDir, 'en.json'))).toBe(true); + expect(fs.existsSync(path.join(copiedMessagesDir, 'de.json'))).toBe(true); + expect(fs.existsSync(path.join(copiedMessagesDir, 'extra', 'fr.json'))).toBe(true); + + const enContent = JSON.parse(await readFileText(path.join(copiedMessagesDir, 'en.json'))); + expect(enContent).toEqual({ greeting: 'Hello' }); + }, 30000); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/babel-transform/schema.json b/packages/nx-infra-plugin/src/executors/babel-transform/schema.json index 0cd7adbb88ec..994099d6d110 100644 --- a/packages/nx-infra-plugin/src/executors/babel-transform/schema.json +++ b/packages/nx-infra-plugin/src/executors/babel-transform/schema.json @@ -37,6 +37,24 @@ "type": "string" }, "description": "Map of file extension renames to apply to output files" + }, + "copyAssets": { + "type": "array", + "description": "Additional asset files or directories to copy verbatim into outDir after transformation", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "Source path relative to project root. Can be a file or directory." + }, + "to": { + "type": "string", + "description": "Destination path relative to outDir." + } + }, + "required": ["from", "to"] + } } }, "required": ["babelConfigPath", "configKey", "sourcePattern", "outDir"], diff --git a/packages/nx-infra-plugin/src/executors/babel-transform/schema.ts b/packages/nx-infra-plugin/src/executors/babel-transform/schema.ts index ff5b4b72d787..c940ca84a383 100644 --- a/packages/nx-infra-plugin/src/executors/babel-transform/schema.ts +++ b/packages/nx-infra-plugin/src/executors/babel-transform/schema.ts @@ -1,3 +1,8 @@ +export interface BabelTransformAsset { + from: string; + to: string; +} + export interface BabelTransformExecutorSchema { babelConfigPath: string; configKey: string; @@ -6,4 +11,5 @@ export interface BabelTransformExecutorSchema { outDir: string; removeDebug?: boolean; renameExtensions?: Record; + copyAssets?: BabelTransformAsset[]; } diff --git a/packages/nx-infra-plugin/src/executors/bundle/bundle.impl.ts b/packages/nx-infra-plugin/src/executors/bundle/bundle.impl.ts index ca3fd5b863ef..f22714cf467c 100644 --- a/packages/nx-infra-plugin/src/executors/bundle/bundle.impl.ts +++ b/packages/nx-infra-plugin/src/executors/bundle/bundle.impl.ts @@ -3,7 +3,11 @@ import * as path from 'path'; import * as fs from 'fs'; import type { Configuration, Stats } from 'webpack'; import { createExecutor } from '../../utils/create-executor'; -import { BundleExecutorSchema } from './schema'; +import { loadProjectPackageJson } from '../../utils/file-operations'; +import type { PackageJson } from '../../utils/types'; +import { applyLicenseHeadersToDirectory } from '../add-license-headers/add-license-headers.impl'; +import { DEFAULT_EULA_URL, resolveLicenseTemplate } from '../add-license-headers/defaults'; +import { BundleExecutorSchema, BundleLicenseHeadersOption } from './schema'; const ERROR_MESSAGES = { ENTRIES_EMPTY: 'entries must contain at least one entry point', @@ -129,6 +133,12 @@ function loadWebpackConfig(resolvedConfigPath: string): Configuration { } } +interface ResolvedLicenseHeaders { + pkg: PackageJson; + templatePath: string; + options: BundleLicenseHeadersOption; +} + interface ResolvedBundle { projectRoot: string; entries: string[]; @@ -138,11 +148,46 @@ interface ResolvedBundle { sourceMap: boolean; webpack: typeof import('webpack'); baseConfig: Configuration; + licenseHeaders?: ResolvedLicenseHeaders; +} + +async function resolveLicenseHeadersStep( + projectRoot: string, + options: BundleLicenseHeadersOption | undefined, +): Promise { + if (!options) { + return undefined; + } + const pkg = await loadProjectPackageJson(projectRoot); + const templatePath = resolveLicenseTemplate(projectRoot, options); + return { pkg, templatePath, options }; +} + +async function applyLicenseHeadersStep( + outDir: string, + resolved: ResolvedLicenseHeaders, +): Promise { + const { pkg, templatePath, options } = resolved; + const count = await applyLicenseHeadersToDirectory({ + targetDir: outDir, + pkg, + templatePath, + eulaUrl: options.eulaUrl ?? DEFAULT_EULA_URL, + mode: options.mode, + version: options.version, + commentType: options.commentType, + separator: options.separator, + prependAfterLicense: options.prependAfterLicense, + filenameMode: options.filenameMode, + includePatterns: options.includePatterns, + excludePatterns: options.excludePatterns, + }); + logger.verbose(`Applied license headers to ${count} bundle file(s)`); } export default createExecutor({ name: 'Bundle', - resolve: (options, { projectRoot }) => { + resolve: async (options, { projectRoot }) => { const { entries, sourceDir, @@ -150,6 +195,7 @@ export default createExecutor({ mode, webpackConfigPath = './webpack.config.js', sourceMap = true, + applyLicenseHeaders, } = options; if (!entries?.length) { @@ -162,6 +208,7 @@ export default createExecutor({ const webpack = loadWebpack(); const baseConfig = loadWebpackConfig(resolvedConfigPath); + const licenseHeaders = await resolveLicenseHeadersStep(projectRoot, applyLicenseHeaders); return { projectRoot, @@ -172,6 +219,7 @@ export default createExecutor({ sourceMap, webpack, baseConfig, + licenseHeaders, }; }, run: async (resolved) => { @@ -204,5 +252,9 @@ export default createExecutor({ const message = error instanceof Error ? error.message : String(error); throw new Error(ERROR_MESSAGES.WEBPACK_ERROR(message)); } + + if (resolved.licenseHeaders) { + await applyLicenseHeadersStep(resolved.resolvedOutDir, resolved.licenseHeaders); + } }, }); diff --git a/packages/nx-infra-plugin/src/executors/bundle/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/bundle/executor.e2e.spec.ts index 15585f957bfb..3a7a05d7c419 100644 --- a/packages/nx-infra-plugin/src/executors/bundle/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/bundle/executor.e2e.spec.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import executor from './executor'; import { BundleExecutorSchema } from './schema'; import { createTempDir, cleanupTempDir, createMockContext } from '../../utils/test-utils'; -import { writeFileText, readFileText } from '../../utils'; +import { writeFileText, writeJson, readFileText } from '../../utils'; const MINIMAL_WEBPACK_CONFIG = ` module.exports = { @@ -101,4 +101,38 @@ describe('BundleExecutor E2E', () => { expect(content).toContain('greet'); expect(content).not.toContain('eval('); }, 60000); + + it('should forward applyLicenseHeaders option to license header pipeline', async () => { + await writeJson(path.join(projectDir, 'package.json'), { + name: 'test-bundle-pkg', + version: '7.8.9', + }); + + const buildDir = path.join(projectDir, 'build', 'gulp'); + fs.mkdirSync(buildDir, { recursive: true }); + await writeFileText( + path.join(buildDir, 'license-header.txt'), + `/*<%= commentType %>\n* DevExtreme (<%= file.relative %>)\n*/\n`, + ); + + const options: BundleExecutorSchema = { + entries: ['bundles/dx.all.js'], + sourceDir: './artifacts/transpiled-renovation-npm', + outDir: './artifacts/js', + mode: 'production', + webpackConfigPath: './webpack.config.js', + applyLicenseHeaders: { + licenseTemplateFile: './build/gulp/license-header.txt', + separator: '', + includePatterns: ['dx.*.js'], + }, + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const bundleContent = await readFileText(path.join(projectDir, 'artifacts', 'js', 'dx.all.js')); + expect(bundleContent).toMatch(/^\/\*!/); + expect(bundleContent).toContain('DevExtreme (dx.all.js)'); + }, 60000); }); diff --git a/packages/nx-infra-plugin/src/executors/bundle/schema.json b/packages/nx-infra-plugin/src/executors/bundle/schema.json index 2751af84ce37..4b19e9742c68 100644 --- a/packages/nx-infra-plugin/src/executors/bundle/schema.json +++ b/packages/nx-infra-plugin/src/executors/bundle/schema.json @@ -29,6 +29,58 @@ "type": "boolean", "description": "Enable eval-source-map devtool in debug mode", "default": true + }, + "applyLicenseHeaders": { + "type": "object", + "description": "Optional post-step that prepends license headers to bundle output files", + "additionalProperties": false, + "properties": { + "licenseTemplateFile": { + "type": "string", + "description": "Path to custom license template file relative to project root" + }, + "mode": { + "type": "string", + "enum": ["eula", "mit"], + "description": "Selects which bundled license template to use when licenseTemplateFile is omitted" + }, + "eulaUrl": { + "type": "string", + "description": "EULA URL for template variable <%= eula %>" + }, + "version": { + "type": "string", + "description": "Version string used in the banner (defaults to pkg.version)" + }, + "commentType": { + "type": "string", + "enum": ["!", "*"], + "description": "Comment marker placed after /* in the banner opening" + }, + "separator": { + "type": "string", + "description": "Separator between banner and original file content" + }, + "prependAfterLicense": { + "type": "string", + "description": "Content prepended after the license banner (e.g. \"\\\"use strict\\\";\\n\\n\")" + }, + "filenameMode": { + "type": "string", + "enum": ["relative", "basename"], + "description": "Filename token used in the banner template" + }, + "includePatterns": { + "type": "array", + "items": { "type": "string" }, + "description": "Glob patterns of files within outDir to add headers to" + }, + "excludePatterns": { + "type": "array", + "items": { "type": "string" }, + "description": "Glob patterns of files within outDir to skip" + } + } } }, "required": ["entries", "sourceDir", "outDir", "mode"], diff --git a/packages/nx-infra-plugin/src/executors/bundle/schema.ts b/packages/nx-infra-plugin/src/executors/bundle/schema.ts index f24ccb334a0a..0f22e11c1209 100644 --- a/packages/nx-infra-plugin/src/executors/bundle/schema.ts +++ b/packages/nx-infra-plugin/src/executors/bundle/schema.ts @@ -1,3 +1,16 @@ +export interface BundleLicenseHeadersOption { + licenseTemplateFile?: string; + mode?: 'eula' | 'mit'; + eulaUrl?: string; + version?: string; + commentType?: '!' | '*'; + separator?: string; + prependAfterLicense?: string; + filenameMode?: 'relative' | 'basename'; + includePatterns?: readonly string[]; + excludePatterns?: readonly string[]; +} + export interface BundleExecutorSchema { entries: string[]; sourceDir: string; @@ -5,4 +18,5 @@ export interface BundleExecutorSchema { mode: 'debug' | 'production'; webpackConfigPath?: string; sourceMap?: boolean; + applyLicenseHeaders?: BundleLicenseHeadersOption; } diff --git a/packages/nx-infra-plugin/src/executors/copy-files/copy-files.impl.ts b/packages/nx-infra-plugin/src/executors/copy-files/copy-files.impl.ts index 3764a8b4d232..88ae0fd6d411 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/copy-files.impl.ts +++ b/packages/nx-infra-plugin/src/executors/copy-files/copy-files.impl.ts @@ -6,13 +6,23 @@ import { glob } from 'glob'; import { createExecutor } from '../../utils/create-executor'; import { toPosixPath } from '../../utils/path-resolver'; import { containsGlobPattern } from '../../utils/common'; -import { copyFile, copyRecursive, ensureDir, exists } from '../../utils/file-operations'; -import { CopyFilesExecutorSchema } from './schema'; +import { + copyFile, + copyRecursive, + ensureDir, + exists, + loadProjectPackageJson, +} from '../../utils/file-operations'; +import { ApplyLicenseHeadersOption, CopyFilesExecutorSchema } from './schema'; +import { applyLicenseHeadersToDirectory } from '../add-license-headers/add-license-headers.impl'; +import { DEFAULT_EULA_URL, resolveLicenseTemplate } from '../add-license-headers/defaults'; const ERROR_FILES_MUST_BE_ARRAY = 'Files option must be an array'; const ERROR_NO_FILES_MATCH_PATTERN = (pattern: string) => `No files found matching pattern: ${pattern}`; const ERROR_SOURCE_NOT_FOUND = (source: string) => `Source file not found: ${source}`; +const ERROR_APPLY_LICENSE_HEADERS_TARGET_SUBDIR_REQUIRED = + 'CopyFiles: applyLicenseHeaders.targetSubdir is required to specify the directory to apply headers to'; export interface CopyDirectoryOptions { include?: string[]; @@ -88,6 +98,35 @@ async function copyDirectPath(sourcePath: string, destPath: string): Promise ${destPath}`); } +async function applyLicenseHeadersIfRequested( + applyLicenseHeaders: ApplyLicenseHeadersOption | undefined, + projectRoot: string, +): Promise { + if (!applyLicenseHeaders) { + return; + } + if (!applyLicenseHeaders.targetSubdir) { + throw new Error(ERROR_APPLY_LICENSE_HEADERS_TARGET_SUBDIR_REQUIRED); + } + const pkg = await loadProjectPackageJson(projectRoot); + const templatePath = resolveLicenseTemplate(projectRoot, applyLicenseHeaders); + const targetDir = path.join(projectRoot, applyLicenseHeaders.targetSubdir); + await applyLicenseHeadersToDirectory({ + targetDir, + pkg, + templatePath, + eulaUrl: applyLicenseHeaders.eulaUrl ?? DEFAULT_EULA_URL, + mode: applyLicenseHeaders.mode, + version: applyLicenseHeaders.version, + commentType: applyLicenseHeaders.commentType, + separator: applyLicenseHeaders.separator, + prependAfterLicense: applyLicenseHeaders.prependAfterLicense, + filenameMode: applyLicenseHeaders.filenameMode, + includePatterns: applyLicenseHeaders.includePatterns, + excludePatterns: applyLicenseHeaders.excludePatterns, + }); +} + interface ResolvedCopyFiles { projectRoot: string; } @@ -111,6 +150,8 @@ export default createExecutor({ await copyDirectPath(sourcePath, destPath); } } + + await applyLicenseHeadersIfRequested(options.applyLicenseHeaders, projectRoot); }, }); diff --git a/packages/nx-infra-plugin/src/executors/copy-files/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/copy-files/executor.e2e.spec.ts index e047d89bfb4d..decff3322211 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/copy-files/executor.e2e.spec.ts @@ -251,4 +251,37 @@ describe('CopyFilesExecutor E2E', () => { const projectDir = path.join(tempDir, 'packages', 'test-lib'); expect(fs.existsSync(path.join(projectDir, 'dist-direct', 'README.md'))).toBe(true); }); + + it('should forward applyLicenseHeaders option to license header pipeline', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const buildDir = path.join(projectDir, 'build', 'gulp'); + fs.mkdirSync(buildDir, { recursive: true }); + await writeFileText( + path.join(buildDir, 'license-header.txt'), + `/*<%= commentType %>\n* DevExtreme (<%= file.relative %>)\n*/\n`, + ); + await writeFileText( + path.join(projectDir, 'aspnet-source.js'), + 'module.exports = function aspnet() {};\n', + ); + + const options: CopyFilesExecutorSchema = { + files: [{ from: './aspnet-source.js', to: './artifacts/js/dx.aspnet.mvc.js' }], + applyLicenseHeaders: { + licenseTemplateFile: './build/gulp/license-header.txt', + targetSubdir: './artifacts/js', + separator: '', + includePatterns: ['dx.aspnet.mvc.js'], + }, + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const copiedContent = await readFileText( + path.join(projectDir, 'artifacts', 'js', 'dx.aspnet.mvc.js'), + ); + expect(copiedContent).toMatch(/^\/\*!/); + expect(copiedContent).toContain('DevExtreme (dx.aspnet.mvc.js)'); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/copy-files/schema.json b/packages/nx-infra-plugin/src/executors/copy-files/schema.json index b4f419373bce..144a1b285a2f 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/schema.json +++ b/packages/nx-infra-plugin/src/executors/copy-files/schema.json @@ -25,6 +25,24 @@ }, "required": ["from", "to"] } + }, + "applyLicenseHeaders": { + "type": "object", + "description": "When provided, applies DevExtreme license headers after copying. The targetSubdir field is required and resolves relative to the project root.", + "properties": { + "licenseTemplateFile": { "type": "string" }, + "mode": { "type": "string", "enum": ["eula", "mit"] }, + "eulaUrl": { "type": "string" }, + "version": { "type": "string" }, + "commentType": { "type": "string", "enum": ["!", "*"] }, + "separator": { "type": "string" }, + "prependAfterLicense": { "type": "string" }, + "filenameMode": { "type": "string", "enum": ["relative", "basename"] }, + "includePatterns": { "type": "array", "items": { "type": "string" } }, + "excludePatterns": { "type": "array", "items": { "type": "string" } }, + "targetSubdir": { "type": "string" } + }, + "required": ["targetSubdir"] } }, "required": ["files"] diff --git a/packages/nx-infra-plugin/src/executors/copy-files/schema.ts b/packages/nx-infra-plugin/src/executors/copy-files/schema.ts index af83c2648650..bfd6b85e95a3 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/schema.ts +++ b/packages/nx-infra-plugin/src/executors/copy-files/schema.ts @@ -1,7 +1,22 @@ +export interface ApplyLicenseHeadersOption { + licenseTemplateFile?: string; + mode?: 'eula' | 'mit'; + eulaUrl?: string; + version?: string; + commentType?: '!' | '*'; + separator?: string; + prependAfterLicense?: string; + filenameMode?: 'relative' | 'basename'; + includePatterns?: readonly string[]; + excludePatterns?: readonly string[]; + targetSubdir?: string; +} + export interface CopyFilesExecutorSchema { files: Array<{ from: string; to: string; excludePatterns?: string[]; }>; + applyLicenseHeaders?: ApplyLicenseHeadersOption; } diff --git a/packages/nx-infra-plugin/src/executors/localization/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/localization/executor.e2e.spec.ts index 72eeda0d567a..b870606b2334 100644 --- a/packages/nx-infra-plugin/src/executors/localization/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/localization/executor.e2e.spec.ts @@ -206,6 +206,33 @@ describe('LocalizationExecutor E2E', () => { } }); + it('should forward applyLicenseHeaders option to license header pipeline', async () => { + const licenseTemplatePath = path.join(fixture.buildDir, 'license-header.txt'); + await writeFileText( + licenseTemplatePath, + `/*<%= commentType %>\n* DevExtreme (<%= file.relative %>)\n*/\n`, + ); + + const options: LocalizationExecutorSchema = { + messagesDir: './js/localization/messages', + messageTemplate: './build/gulp/localization-template.jst', + messageOutputDir: './artifacts/js/localization', + skipCldrGeneration: true, + applyLicenseHeaders: { + licenseTemplateFile: './build/gulp/license-header.txt', + separator: '', + includePatterns: ['**/*.js'], + }, + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const enContent = await readFileText(path.join(fixture.artifactsDir, MESSAGE_FILE.EN)); + expect(enContent).toMatch(/^\/\*!/); + expect(enContent).toContain('DevExtreme (dx.messages.en.js)'); + }); + it('should have correct output structure', async () => { const options: LocalizationExecutorSchema = { messagesDir: './js/localization/messages', diff --git a/packages/nx-infra-plugin/src/executors/localization/localization.impl.ts b/packages/nx-infra-plugin/src/executors/localization/localization.impl.ts index 6847d22a1bc6..d6498442879c 100644 --- a/packages/nx-infra-plugin/src/executors/localization/localization.impl.ts +++ b/packages/nx-infra-plugin/src/executors/localization/localization.impl.ts @@ -4,8 +4,15 @@ import * as fs from 'fs'; import { createRequire } from 'module'; import _ from 'lodash'; import { createExecutor } from '../../utils/create-executor'; -import { readFileText, writeFileText, readJson } from '../../utils/file-operations'; -import { LocalizationExecutorSchema } from './schema'; +import { + loadProjectPackageJson, + readFileText, + writeFileText, + readJson, +} from '../../utils/file-operations'; +import { ApplyLicenseHeadersOption, LocalizationExecutorSchema } from './schema'; +import { applyLicenseHeadersToDirectory } from '../add-license-headers/add-license-headers.impl'; +import { DEFAULT_EULA_URL, resolveLicenseTemplate } from '../add-license-headers/defaults'; interface CldrInstance { supplemental: { @@ -368,6 +375,35 @@ async function generateCldrModules( } } +async function applyLicenseHeadersIfRequested( + applyLicenseHeaders: ApplyLicenseHeadersOption | undefined, + projectRoot: string, + defaultTargetDir: string, +): Promise { + if (!applyLicenseHeaders) { + return; + } + const pkg = await loadProjectPackageJson(projectRoot); + const templatePath = resolveLicenseTemplate(projectRoot, applyLicenseHeaders); + const targetDir = applyLicenseHeaders.targetSubdir + ? path.join(projectRoot, applyLicenseHeaders.targetSubdir) + : defaultTargetDir; + await applyLicenseHeadersToDirectory({ + targetDir, + pkg, + templatePath, + eulaUrl: applyLicenseHeaders.eulaUrl ?? DEFAULT_EULA_URL, + mode: applyLicenseHeaders.mode, + version: applyLicenseHeaders.version, + commentType: applyLicenseHeaders.commentType, + separator: applyLicenseHeaders.separator, + prependAfterLicense: applyLicenseHeaders.prependAfterLicense, + filenameMode: applyLicenseHeaders.filenameMode, + includePatterns: applyLicenseHeaders.includePatterns, + excludePatterns: applyLicenseHeaders.excludePatterns, + }); +} + interface ResolvedLocalization { projectRoot: string; messagesDir: string; @@ -379,6 +415,7 @@ interface ResolvedLocalization { skipCldrGeneration: boolean; skipMessageGeneration: boolean; lintGeneratedFiles: boolean; + applyLicenseHeaders?: ApplyLicenseHeadersOption; } export default createExecutor({ @@ -417,6 +454,7 @@ export default createExecutor( skipCldrGeneration: options.skipCldrGeneration ?? false, skipMessageGeneration: options.skipMessageGeneration ?? false, lintGeneratedFiles: options.lintGeneratedFiles ?? true, + applyLicenseHeaders: options.applyLicenseHeaders, }; }, run: async (resolved) => { @@ -459,6 +497,12 @@ export default createExecutor( logger.verbose(`CLDR modules generated in ${resolved.cldrDataOutputDir}`); } + await applyLicenseHeadersIfRequested( + resolved.applyLicenseHeaders, + resolved.projectRoot, + resolved.messageOutputDir, + ); + logger.verbose('Localization generation completed successfully'); }, }); diff --git a/packages/nx-infra-plugin/src/executors/localization/schema.json b/packages/nx-infra-plugin/src/executors/localization/schema.json index b8579581a2dc..9b98f25a7e46 100644 --- a/packages/nx-infra-plugin/src/executors/localization/schema.json +++ b/packages/nx-infra-plugin/src/executors/localization/schema.json @@ -48,6 +48,23 @@ "type": "boolean", "description": "Skip message file generation (only generate CLDR TypeScript files)", "default": false + }, + "applyLicenseHeaders": { + "type": "object", + "description": "When provided, applies DevExtreme license headers to the executor output. Defaults the target directory to messageOutputDir; override via targetSubdir.", + "properties": { + "licenseTemplateFile": { "type": "string" }, + "mode": { "type": "string", "enum": ["eula", "mit"] }, + "eulaUrl": { "type": "string" }, + "version": { "type": "string" }, + "commentType": { "type": "string", "enum": ["!", "*"] }, + "separator": { "type": "string" }, + "prependAfterLicense": { "type": "string" }, + "filenameMode": { "type": "string", "enum": ["relative", "basename"] }, + "includePatterns": { "type": "array", "items": { "type": "string" } }, + "excludePatterns": { "type": "array", "items": { "type": "string" } }, + "targetSubdir": { "type": "string" } + } } }, "required": [] diff --git a/packages/nx-infra-plugin/src/executors/localization/schema.ts b/packages/nx-infra-plugin/src/executors/localization/schema.ts index e2ff8efe1db5..72873e387c3f 100644 --- a/packages/nx-infra-plugin/src/executors/localization/schema.ts +++ b/packages/nx-infra-plugin/src/executors/localization/schema.ts @@ -1,3 +1,17 @@ +export interface ApplyLicenseHeadersOption { + licenseTemplateFile?: string; + mode?: 'eula' | 'mit'; + eulaUrl?: string; + version?: string; + commentType?: '!' | '*'; + separator?: string; + prependAfterLicense?: string; + filenameMode?: 'relative' | 'basename'; + includePatterns?: readonly string[]; + excludePatterns?: readonly string[]; + targetSubdir?: string; +} + export interface LocalizationExecutorSchema { messagesDir?: string; messageTemplate?: string; @@ -8,4 +22,5 @@ export interface LocalizationExecutorSchema { lintGeneratedFiles?: boolean; skipCldrGeneration?: boolean; skipMessageGeneration?: boolean; + applyLicenseHeaders?: ApplyLicenseHeadersOption; } diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts index 7568816ba6fc..41e8cb269d30 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts @@ -110,4 +110,101 @@ describe('NpmAssembleExecutor E2E', () => { expect(fs.existsSync(path.join(distDir, 'js', 'dx.all.js'))).toBe(true); expect(fs.existsSync(path.join(distDir, 'js', 'jquery.js'))).toBe(false); }); + + it('should not produce metadata or flatten artifacts when neither option is set', async () => { + const result = await executor(OPTIONS, context); + expect(result.success).toBe(true); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + const distArtifacts = path.join(projectDir, 'artifacts', 'npm', 'devextreme-dist'); + + expect(fs.existsSync(path.join(outDir, 'README.md'))).toBe(false); + expect(fs.existsSync(path.join(outDir, '.npmignore'))).toBe(false); + expect(fs.existsSync(distArtifacts)).toBe(false); + }); + + it('should copy metadata files relative to projectRoot/outputDir when provided', async () => { + fs.mkdirSync(path.join(projectDir, 'build', 'npm-templates'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'packages', 'devextreme-dist'), { recursive: true }); + + await writeFileText(path.join(tempDir, 'README.md'), 'workspace readme'); + await writeFileText(path.join(projectDir, 'build', 'npm-templates', '.npmignore'), '*.log\n'); + await writeFileText( + path.join(tempDir, 'packages', 'devextreme-dist', 'README.md'), + 'dist readme', + ); + await writeFileText( + path.join(tempDir, 'packages', 'devextreme-dist', 'LICENSE.md'), + 'dist license', + ); + + const optionsWithMetadata: NpmAssembleExecutorSchema = { + ...OPTIONS, + metadataFiles: [ + { from: '../../README.md', to: './README.md' }, + { from: './build/npm-templates/.npmignore', to: './.npmignore' }, + { from: '../devextreme-dist/README.md', to: '../devextreme-dist/README.md' }, + { from: '../devextreme-dist/LICENSE.md', to: '../devextreme-dist/LICENSE.md' }, + ], + }; + + const result = await executor(optionsWithMetadata, context); + expect(result.success).toBe(true); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + const distMetaDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme-dist'); + + expect(await readFileText(path.join(outDir, 'README.md'))).toBe('workspace readme'); + expect(await readFileText(path.join(outDir, '.npmignore'))).toBe('*.log\n'); + expect(await readFileText(path.join(distMetaDir, 'README.md'))).toBe('dist readme'); + expect(await readFileText(path.join(distMetaDir, 'LICENSE.md'))).toBe('dist license'); + }); + + it('should flatten outputDir sub-trees into a secondary dir relative to projectRoot', async () => { + const artifactsDir = path.join(projectDir, 'artifacts'); + fs.mkdirSync(path.join(artifactsDir, 'js'), { recursive: true }); + fs.mkdirSync(path.join(artifactsDir, 'css'), { recursive: true }); + + await writeFileText(path.join(artifactsDir, 'js', 'dx.all.js'), 'var dx = {};'); + await writeFileText(path.join(artifactsDir, 'css', 'dx.light.css'), '.dx { }'); + + const optionsWithFlatten: NpmAssembleExecutorSchema = { + ...OPTIONS, + flatten: [{ from: './dist', to: './artifacts/npm/devextreme-dist' }], + }; + + const result = await executor(optionsWithFlatten, context); + expect(result.success).toBe(true); + + const flattenedDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme-dist'); + + expect(fs.existsSync(path.join(flattenedDir, 'js', 'dx.all.js'))).toBe(true); + expect(fs.existsSync(path.join(flattenedDir, 'css', 'dx.light.css'))).toBe(true); + }); + + it('should support metadataFiles and flatten together', async () => { + const artifactsDir = path.join(projectDir, 'artifacts'); + fs.mkdirSync(path.join(artifactsDir, 'js'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'packages', 'devextreme-dist'), { recursive: true }); + + await writeFileText(path.join(artifactsDir, 'js', 'dx.all.js'), 'var dx = {};'); + await writeFileText( + path.join(tempDir, 'packages', 'devextreme-dist', 'README.md'), + 'dist readme', + ); + + const combinedOptions: NpmAssembleExecutorSchema = { + ...OPTIONS, + metadataFiles: [{ from: '../devextreme-dist/README.md', to: '../devextreme-dist/README.md' }], + flatten: [{ from: './dist', to: './artifacts/npm/devextreme-dist' }], + }; + + const result = await executor(combinedOptions, context); + expect(result.success).toBe(true); + + const distMetaDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme-dist'); + + expect(await readFileText(path.join(distMetaDir, 'README.md'))).toBe('dist readme'); + expect(fs.existsSync(path.join(distMetaDir, 'js', 'dx.all.js'))).toBe(true); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts index 72502add24e0..5af28f14a1f9 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts @@ -17,7 +17,11 @@ import { copyDirectory } from '../copy-files/copy-files.impl'; import { applyLicenseHeadersToDirectory } from '../add-license-headers/add-license-headers.impl'; import { DEFAULT_EULA_URL, resolveLicenseTemplate } from '../add-license-headers/defaults'; import type { PackageJson } from '../../utils/types'; -import { NpmAssembleExecutorSchema } from './schema'; +import { + NpmAssembleExecutorSchema, + NpmAssembleFlattenStep, + NpmAssembleMetadataFile, +} from './schema'; const SRC_JS_EXCLUDES = [ 'bundles/*.js', @@ -115,6 +119,16 @@ async function copyDistFiles(artifactsDir: string, outputDir: string): Promise ({ + from: path.resolve(projectRoot, entry.from), + to: path.resolve(outputDir, entry.to), + })); +} + +function resolveFlattenSteps( + entries: NpmAssembleFlattenStep[] | undefined, + projectRoot: string, + outputDir: string, +): ResolvedFlattenStep[] { + if (!entries) { + return []; + } + return entries.map((entry) => ({ + from: path.resolve(outputDir, entry.from), + to: path.resolve(projectRoot, entry.to), + })); +} + +async function copyMetadataFiles(entries: ResolvedMetadataFile[]): Promise { + await Promise.all(entries.map((entry) => copyFile(entry.from, entry.to))); +} + +async function applyFlattenSteps(entries: ResolvedFlattenStep[]): Promise { + for (const entry of entries) { + await copyDirectory(entry.from, entry.to); + } } export default createExecutor({ @@ -133,6 +187,7 @@ export default createExecutor({ resolve: async (options, { projectRoot }) => { const pkg = await loadProjectPackageJson(projectRoot); const templatePath = resolveLicenseTemplate(projectRoot, options); + const outputDir = path.resolve(projectRoot, options.outputDir); return { pkg, @@ -144,7 +199,9 @@ export default createExecutor({ npmBinDir: path.resolve(projectRoot, options.npmBinDir), webpackConfigSrc: path.resolve(projectRoot, options.webpackConfig), artifactsDir: path.resolve(projectRoot, options.artifactsDir), - outputDir: path.resolve(projectRoot, options.outputDir), + outputDir, + metadataFiles: resolveMetadataFiles(options.metadataFiles, projectRoot, outputDir), + flattenSteps: resolveFlattenSteps(options.flatten, projectRoot, outputDir), }; }, run: async (resolved) => { @@ -176,5 +233,15 @@ export default createExecutor({ filenameMode: 'relative', }); logger.verbose('Applied star-license banners to source JS files'); + + if (resolved.metadataFiles.length > 0) { + await copyMetadataFiles(resolved.metadataFiles); + logger.verbose('Copied metadata files to output directory'); + } + + if (resolved.flattenSteps.length > 0) { + await applyFlattenSteps(resolved.flattenSteps); + logger.verbose('Applied flatten steps from output directory'); + } }, }); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json index 1615bfba955d..2ff79ad68190 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json @@ -38,6 +38,42 @@ "eulaUrl": { "type": "string", "description": "EULA URL embedded in the license header." + }, + "metadataFiles": { + "type": "array", + "description": "Metadata files (README, LICENSE, .npmignore, etc.) to copy after assembly. 'from' is resolved against projectRoot; 'to' is resolved against outputDir.", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "Source file path, resolved against projectRoot." + }, + "to": { + "type": "string", + "description": "Destination file path, resolved against outputDir." + } + }, + "required": ["from", "to"] + } + }, + "flatten": { + "type": "array", + "description": "Secondary directories populated by recursively copying a sub-tree from outputDir. 'from' is resolved against outputDir; 'to' is resolved against projectRoot.", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "Source directory inside outputDir, resolved as outputDir/from." + }, + "to": { + "type": "string", + "description": "Destination directory, resolved against projectRoot." + } + }, + "required": ["from", "to"] + } } }, "required": [ diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts index 9ccabbf782cd..c428da3ffc1d 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts @@ -1,3 +1,13 @@ +export interface NpmAssembleMetadataFile { + from: string; + to: string; +} + +export interface NpmAssembleFlattenStep { + from: string; + to: string; +} + export interface NpmAssembleExecutorSchema { transpiledDir: string; jsSrcDir: string; @@ -8,4 +18,6 @@ export interface NpmAssembleExecutorSchema { outputDir: string; licenseTemplateFile?: string; eulaUrl?: string; + metadataFiles?: NpmAssembleMetadataFile[]; + flatten?: NpmAssembleFlattenStep[]; } diff --git a/packages/nx-infra-plugin/src/executors/vectormap/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/vectormap/executor.e2e.spec.ts index 274e69ed1e06..f324d2633f70 100644 --- a/packages/nx-infra-plugin/src/executors/vectormap/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/vectormap/executor.e2e.spec.ts @@ -69,6 +69,20 @@ function makeOptions(): VectormapExecutorSchema { }; } +const LICENSE_TEMPLATE = `/*<%= commentType %> +* Vectormap (<%= file.relative %>) +* Version: <%= version %> +*/ +`; + +async function setupLicenseTemplate(projectDir: string): Promise { + const buildDir = path.join(projectDir, 'build', 'gulp'); + fs.mkdirSync(buildDir, { recursive: true }); + const templatePath = path.join(buildDir, 'license-header.txt'); + await writeFileText(templatePath, LICENSE_TEMPLATE); + return './build/gulp/license-header.txt'; +} + describe('VectormapExecutor E2E', () => { let tempDir: string; let context = createMockContext(); @@ -110,4 +124,23 @@ describe('VectormapExecutor E2E', () => { expect(worldData).toContain('"precision":4'); expect(worldData).toContain('"firstShpByte":1'); }, 30000); + + it('should forward applyLicenseHeaders option to license header pipeline', async () => { + const licenseTemplateFile = await setupLicenseTemplate(projectDir); + const options: VectormapExecutorSchema = { + ...makeOptions(), + applyLicenseHeaders: { + licenseTemplateFile, + separator: '', + }, + }; + + const result = await executor(options, context); + expect(result.success).toBe(true); + + const utilsDir = path.join(projectDir, 'artifacts', 'js', 'vectormap-utils'); + const productionContent = await readFileText(path.join(utilsDir, 'dx.vectormaputils.js')); + expect(productionContent).toMatch(/^\/\*!/); + expect(productionContent).toContain('Vectormap (dx.vectormaputils.js)'); + }, 30000); }); diff --git a/packages/nx-infra-plugin/src/executors/vectormap/schema.json b/packages/nx-infra-plugin/src/executors/vectormap/schema.json index 0cc4123e6554..6da755653cff 100644 --- a/packages/nx-infra-plugin/src/executors/vectormap/schema.json +++ b/packages/nx-infra-plugin/src/executors/vectormap/schema.json @@ -33,6 +33,23 @@ "dataTemplatePath": { "type": "string", "description": "Path to vectormapdata UMD template file" + }, + "applyLicenseHeaders": { + "type": "object", + "description": "When provided, applies DevExtreme license headers to the executor output. Defaults the target directory to utilsOutDir; override via targetSubdir.", + "properties": { + "licenseTemplateFile": { "type": "string" }, + "mode": { "type": "string", "enum": ["eula", "mit"] }, + "eulaUrl": { "type": "string" }, + "version": { "type": "string" }, + "commentType": { "type": "string", "enum": ["!", "*"] }, + "separator": { "type": "string" }, + "prependAfterLicense": { "type": "string" }, + "filenameMode": { "type": "string", "enum": ["relative", "basename"] }, + "includePatterns": { "type": "array", "items": { "type": "string" } }, + "excludePatterns": { "type": "array", "items": { "type": "string" } }, + "targetSubdir": { "type": "string" } + } } }, "required": [ diff --git a/packages/nx-infra-plugin/src/executors/vectormap/schema.ts b/packages/nx-infra-plugin/src/executors/vectormap/schema.ts index c3fad1a85c0e..4aeb10336259 100644 --- a/packages/nx-infra-plugin/src/executors/vectormap/schema.ts +++ b/packages/nx-infra-plugin/src/executors/vectormap/schema.ts @@ -1,3 +1,17 @@ +export interface ApplyLicenseHeadersOption { + licenseTemplateFile?: string; + mode?: 'eula' | 'mit'; + eulaUrl?: string; + version?: string; + commentType?: '!' | '*'; + separator?: string; + prependAfterLicense?: string; + filenameMode?: 'relative' | 'basename'; + includePatterns?: readonly string[]; + excludePatterns?: readonly string[]; + targetSubdir?: string; +} + export interface VectormapExecutorSchema { sourceDir: string; settingsFile: string; @@ -7,4 +21,5 @@ export interface VectormapExecutorSchema { dataOutDir: string; utilsTemplatePath: string; dataTemplatePath: string; + applyLicenseHeaders?: ApplyLicenseHeadersOption; } diff --git a/packages/nx-infra-plugin/src/executors/vectormap/vectormap.impl.ts b/packages/nx-infra-plugin/src/executors/vectormap/vectormap.impl.ts index 28d3b82f0472..3d83e898ca22 100644 --- a/packages/nx-infra-plugin/src/executors/vectormap/vectormap.impl.ts +++ b/packages/nx-infra-plugin/src/executors/vectormap/vectormap.impl.ts @@ -3,14 +3,17 @@ import * as path from 'path'; import * as fs from 'fs'; import * as _ from 'lodash'; import { createExecutor } from '../../utils/create-executor'; -import { VectormapExecutorSchema } from './schema'; +import { ApplyLicenseHeadersOption, VectormapExecutorSchema } from './schema'; import { ensureDir, + loadProjectPackageJson, readFileText, writeFileText, normalizeEol, ensureTrailingNewline, } from '../../utils/file-operations'; +import { applyLicenseHeadersToDirectory } from '../add-license-headers/add-license-headers.impl'; +import { DEFAULT_EULA_URL, resolveLicenseTemplate } from '../add-license-headers/defaults'; interface UtilsSettings { commonFiles: string[]; @@ -177,6 +180,35 @@ async function buildData( await writeRegionModules(regions, dataTemplate, resolvedOutDir); } +async function applyLicenseHeadersIfRequested( + applyLicenseHeaders: ApplyLicenseHeadersOption | undefined, + projectRoot: string, + defaultTargetDir: string, +): Promise { + if (!applyLicenseHeaders) { + return; + } + const pkg = await loadProjectPackageJson(projectRoot); + const templatePath = resolveLicenseTemplate(projectRoot, applyLicenseHeaders); + const targetDir = applyLicenseHeaders.targetSubdir + ? path.join(projectRoot, applyLicenseHeaders.targetSubdir) + : defaultTargetDir; + await applyLicenseHeadersToDirectory({ + targetDir, + pkg, + templatePath, + eulaUrl: applyLicenseHeaders.eulaUrl ?? DEFAULT_EULA_URL, + mode: applyLicenseHeaders.mode, + version: applyLicenseHeaders.version, + commentType: applyLicenseHeaders.commentType, + separator: applyLicenseHeaders.separator, + prependAfterLicense: applyLicenseHeaders.prependAfterLicense, + filenameMode: applyLicenseHeaders.filenameMode, + includePatterns: applyLicenseHeaders.includePatterns, + excludePatterns: applyLicenseHeaders.excludePatterns, + }); +} + interface ResolvedVectormap { projectRoot: string; resolvedSourceDir: string; @@ -187,6 +219,7 @@ interface ResolvedVectormap { dataOutDir: string; resolvedUtilsTemplatePath: string; resolvedDataTemplatePath: string; + applyLicenseHeaders?: ApplyLicenseHeadersOption; } export default createExecutor({ @@ -202,6 +235,7 @@ export default createExecutor({ dataOutDir: options.dataOutDir, resolvedUtilsTemplatePath: path.resolve(projectRoot, options.utilsTemplatePath), resolvedDataTemplatePath: path.resolve(projectRoot, options.dataTemplatePath), + applyLicenseHeaders: options.applyLicenseHeaders, }; }, run: async (resolved) => { @@ -226,5 +260,11 @@ export default createExecutor({ .readdirSync(path.resolve(resolved.projectRoot, resolved.dataOutDir)) .filter((entry) => entry.endsWith('.js')); logger.verbose(`Phase 2 complete: ${dataFiles.length} region modules produced`); + + await applyLicenseHeadersIfRequested( + resolved.applyLicenseHeaders, + resolved.projectRoot, + resolved.resolvedUtilsOutDir, + ); }, }); From e0218fe548b2fd623740e4c595434cdb4fedd839 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Mon, 4 May 2026 20:09:49 +0300 Subject: [PATCH 16/27] chore(nx-infra-plugin): default eulaUrl to DEFAULT_EULA_URL in compress executor --- packages/devextreme/project.json | 18 ++++++------------ .../src/executors/compress/compress.impl.ts | 3 ++- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index caf4b43a792b..3dc196bffc35 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -608,10 +608,7 @@ "./artifacts/js/dx.ai-integration.debug.js", "./artifacts/js/dx.custom.debug.js" ], - "mode": { - "name": "beautify", - "eulaUrl": "https://js.devexpress.com/Licensing/" - } + "mode": { "name": "beautify" } }, "prod": { "files": [ @@ -629,10 +626,7 @@ "./artifacts/js/dx.viz.js", "./artifacts/js/dx.ai-integration.js" ], - "mode": { - "name": "minify", - "eulaUrl": "https://js.devexpress.com/Licensing/" - } + "mode": { "name": "minify" } } }, "inputs": [ @@ -690,13 +684,13 @@ "files": [ "./artifacts/js/vectormap-utils/dx.vectormaputils.debug.js" ], - "mode": { "name": "beautify", "eulaUrl": "https://js.devexpress.com/Licensing/" } + "mode": { "name": "beautify" } }, "minify": { "files": [ "./artifacts/js/vectormap-utils/dx.vectormaputils.js" ], - "mode": { "name": "minify", "eulaUrl": "https://js.devexpress.com/Licensing/" } + "mode": { "name": "minify" } } } }, @@ -748,7 +742,7 @@ "executor": "devextreme-nx-infra-plugin:compress", "options": { "files": ["./artifacts/js/dx.aspnet.mvc.js"], - "mode": { "name": "beautify", "eulaUrl": "https://js.devexpress.com/Licensing/" } + "mode": { "name": "beautify" } }, "configurations": { "normalize": { @@ -935,7 +929,7 @@ }, "configurations": { "production": { - "mode": { "name": "beautify", "eulaUrl": "https://js.devexpress.com/Licensing/" } + "mode": { "name": "beautify" } } }, "inputs": [ diff --git a/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts b/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts index 1278767fef36..54eaf9bfffa2 100644 --- a/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts +++ b/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts @@ -11,6 +11,7 @@ import { writeFileText, } from '../../utils/file-operations'; import { CompressExecutorSchema, CompressMode, CompressModeName } from './schema'; +import { DEFAULT_EULA_URL } from '../add-license-headers/defaults'; const STRIP_DEBUG_REGEX = /\/{2,}\s{0,}#DEBUG[\s\S]*?\/{2,}\s{0,}#ENDDEBUG/g; @@ -104,7 +105,7 @@ function resolveMode(mode: CompressMode): ResolvedMode { } const trailingNewline = mode.trailingNewline ?? true; if (mode.name === 'minify' || mode.name === 'beautify') { - return { name: mode.name, eulaUrl: mode.eulaUrl, trailingNewline }; + return { name: mode.name, eulaUrl: mode.eulaUrl ?? DEFAULT_EULA_URL, trailingNewline }; } return { name: mode.name, trailingNewline }; } From 4a3913df5f17879dad41bd81adbcc867dfabd3b2 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Wed, 6 May 2026 12:36:45 +0300 Subject: [PATCH 17/27] docs(nx-infra-plugin): add AGENTS.md --- packages/nx-infra-plugin/AGENTS.md | 64 ++++++++++++++++++++++++++++++ packages/nx-infra-plugin/CLAUDE.md | 1 + 2 files changed, 65 insertions(+) create mode 100644 packages/nx-infra-plugin/AGENTS.md create mode 100644 packages/nx-infra-plugin/CLAUDE.md diff --git a/packages/nx-infra-plugin/AGENTS.md b/packages/nx-infra-plugin/AGENTS.md new file mode 100644 index 000000000000..bf94cd2d9d9e --- /dev/null +++ b/packages/nx-infra-plugin/AGENTS.md @@ -0,0 +1,64 @@ +# nx-infra-plugin + +Nx workspace plugin providing build-pipeline executors for the DevExtreme monorepo. Each executor follows a two-tier impl pattern. + +## Commands + +```bash +pnpm --workspace-root nx test devextreme-nx-infra-plugin +pnpm --workspace-root nx lint devextreme-nx-infra-plugin +pnpm --filter devextreme-nx-infra-plugin run build +pnpm --filter devextreme-nx-infra-plugin exec tsc --noEmit -p tsconfig.lib.json +pnpm --filter devextreme-nx-infra-plugin exec jest src/executors//executor.e2e.spec.ts +pnpm --filter devextreme-nx-infra-plugin exec prettier --write . +``` + +All tests must pass before commit. + +## Structure + +Each executor lives at `src/executors//`: + +- `executor.ts` — thin re-export: `export { default } from './.impl';` +- `.impl.ts` — business logic via `createExecutor` + named exports for cross-executor reuse +- `schema.ts`, `schema.json`, `executor.e2e.spec.ts`, optional `defaults.ts` + +Each cross-executor concern (license banner, glob-aware copy, file concatenation, debug-block stripping, etc.) is owned by exactly ONE executor and exposed via named exports from its `*.impl.ts`. Discover what is available by reading the named exports of the relevant executor; do not re-implement. The full executor catalogue is in `executors.json`; generic primitives live in `src/utils/`. + +## Conventions + +- Wrap every executor with `createExecutor` (see `src/utils/create-executor.ts`). Do not duplicate project-root resolution or try/catch. +- Expose reusable logic as named exports from `.impl.ts`; consumers import from `..//.impl`. +- Collapse `src/utils/X.ts` files that exist only because an executor's logic was needed elsewhere — move into the owner executor's impl. +- Throw inside `resolve` and `run`; the wrapper converts to `{ success: false }`. +- Keep the default export shape `PromiseExecutor`. Tests import `from './executor'`. +- Use `logger.verbose(...)` from `@nx/devkit` for diagnostic output in executors. Never use `console.log` or `logger.info` for routine progress messages — they pollute every run; `logger.verbose` surfaces only when callers pass `--verbose`. + +## Constraints + +- NEVER edit `executors.json` for refactors; it points to `./src/executors//executor` and the build script rewrites paths in `dist`. Re-export from `executor.ts` instead. +- NEVER call another executor's `default` export from a sibling; import the named function from its `*.impl.ts` instead. +- NEVER use `runExecutor` from `@nx/devkit` for in-plugin composition; reserve it for cross-target / cross-project orchestration. + +## Testing + +Each behavior is owned by exactly ONE executor's canonical tests; consumers must not re-test owned behavior. Consumers test wiring + their own unique logic only. + +- When a consumer executor uses another's named function, write ONE smoke test that verifies the option is forwarded (one artifact-presence assertion). Don't re-assert the helper's behavior — that is the owner's test job. +- Drop "should not modify X when option is omitted" negative tests; absence of behavior is implied by code structure and the createExecutor wrapper. +- Don't repeat the same assertion across multiple fixtures — pick one representative per code path. +- Test setup (license template literal, mock context, temp dirs) goes through helpers from `../../utils/test-utils`. Don't reinvent. + +## Add a new executor + +1. Create `src/executors//` with `schema.json`, `schema.ts`, `.impl.ts` using `createExecutor`, `executor.ts` re-exporting `default` plus any named functions, and `executor.e2e.spec.ts` using `createMockContext` + `createTempDir` from `../../utils/test-utils`. +2. Register in `executors.json`: `implementation: ./src/executors//executor`, `schema: ./src/executors//schema.json`. +3. Validate: tsc → jest → lint. All tests must still pass. + +## Refactor an existing executor + +1. Run `grep -rn "" src/executors/`. If 3+ executors share a pattern, it is a centralization candidate. +2. If the pattern belongs to an existing executor's domain, add a named export there. Otherwise add to `src/utils/`. +3. Preserve exact functional parity. Verify with the executor's e2e spec before and after. +4. Update consumer imports in one batch. +5. Run the full validation pipeline. diff --git a/packages/nx-infra-plugin/CLAUDE.md b/packages/nx-infra-plugin/CLAUDE.md new file mode 100644 index 000000000000..43c994c2d361 --- /dev/null +++ b/packages/nx-infra-plugin/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 7a4ac30a96425352e34cdd3b0173af246215a143 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Wed, 6 May 2026 12:37:14 +0300 Subject: [PATCH 18/27] docs(devextreme): update copilot-instructions.md --- .github/copilot-instructions.md | 531 ++++---------------------------- 1 file changed, 67 insertions(+), 464 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 49ff37314634..1f40233b7325 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,492 +1,95 @@ -# DevExtreme Monorepo - Copilot Instructions +# DevExtreme Monorepo -## Repository Overview +DevExtreme is an enterprise-ready suite of UI components for Angular, React, Vue, and jQuery, distributed as a pnpm/Nx monorepo containing the core library, framework wrappers, themes, themebuilder, and test suites. Stack: TypeScript, JavaScript, SCSS, pnpm + Nx, Node, Gulp + custom Nx executors (`devextreme-nx-infra-plugin`). The .NET SDK is required for `devextreme-internal-tools` code generation. -**DevExtreme** is an enterprise-ready suite of powerful UI components for Angular, React, Vue, and jQuery. This is a large-scale monorepo containing the core library, framework wrappers, demos, and extensive test suites. +## Commands -**Repository Stats:** -- **Type:** Monorepo (pnpm workspaces + Nx) -- **Size:** Large (1000+ files across multiple packages) -- **Languages:** TypeScript, JavaScript, SCSS -- **Package Manager:** pnpm 9.15.4 (specified in package.json) -- **Node Version:** 20.x (required by CI) -- **Build System:** Gulp + Nx + custom build scripts + custom Nx executors (via `devextreme-nx-infra-plugin`) -- **Test Frameworks:** QUnit, Jest, TestCafe, Karma (Angular) - -## Critical Setup Requirements - -### Environment Prerequisites - -**ALWAYS install dependencies with frozen lockfile:** ```bash +# Install (frozen lockfile is mandatory; CI fails otherwise) pnpm install --frozen-lockfile -``` - -**Node.js:** Version 20.x is required (CI uses Node 20) -**pnpm:** Version 9.15.4 (managed via packageManager field) -**.NET SDK:** Version 8.0.x required for running devextreme-internal-tools (uses .NET tool for code generation) - -### First-Time Setup - -1. **Install dependencies from repository root:** - ```bash - pnpm install --frozen-lockfile - ``` - -2. **For development builds of devextreme package:** - ```bash - pnpm exec nx build:dev devextreme - ``` - OR from monorepo root: - ```bash - pnpm run all:build-dev - ``` -3. **For production builds:** - ```bash - pnpm run all:build - ``` - -## Repository Structure - -### Key Directories - -``` -/packages/ - devextreme/ # Core library (main package) - js/ # JavaScript/TypeScript source code - ui/ # UI widgets - viz/ # Visualization components - core/ # Core utilities - data/ # Data layer - renovation/ # Renovation components (new architecture) - testing/ # QUnit tests - build/ # Build scripts and Gulp tasks - artifacts/ # Build output (generated) - devextreme-angular/ # Angular wrapper - devextreme-react/ # React wrapper - devextreme-vue/ # Vue wrapper - devextreme-scss/ # SCSS themes and styles - devextreme-themebuilder/ # Theme builder package - devextreme-metadata/ # Metadata generation for wrappers - devextreme-monorepo-tools/ # Internal tooling - nx-infra-plugin/ # Custom Nx executors for build automation - workflows/ # Cross-package NX build orchestration (all:build-dev, all:build-testing) - testcafe-models/ # TestCafe page object models - -/apps/ - demos/ # Technical demos (Angular, React, Vue, jQuery) - angular/ # Angular playground - react/ # React playground - vue/ # Vue playground - react-storybook/ # Storybook for React components - -/e2e/ - testcafe-devextreme/ # TestCafe end-to-end tests - wrappers/ # Wrapper integration tests - bundlers/ # Bundler compatibility tests - compilation-cases/ # TypeScript compilation tests - -/tools/scripts/ # Build and utility scripts -``` +# Build +pnpm run all:build-dev # all packages, dev mode (DEVEXTREME_TEST_CI=true) +pnpm run all:build # full production build +pnpm nx build:dev devextreme # single package, dev mode +pnpm nx build devextreme -c=testing # CI testing configuration +pnpm nx build:transpile devextreme # transpile only (babel-transform / build-typescript) +pnpm nx bundle:debug devextreme # debug bundle (Webpack via nx-infra-plugin) +pnpm nx bundle:prod devextreme # production bundle +pnpm nx build:localization devextreme # localization files only +pnpm nx build:npm devextreme # npm package preparation -### Configuration Files - -- **Root:** `nx.json`, `pnpm-workspace.yaml`, `tsconfig.json`, `package.json` -- **Linting:** `.lintstagedrc`, `eslint.config.mjs` (per package) -- **Styles:** `.stylelintrc.json` (in devextreme-scss) -- **Git Hooks:** `.husky/pre-commit` (runs lint-staged) - -## Build System - -### Build Commands (from root) - -**Development build (faster, for testing):** -```bash -pnpm run all:build-dev -``` -- Sets `DEVEXTREME_TEST_CI=TRUE` -- Skips some production optimizations -- Builds all packages - -**Production build (full):** -```bash -pnpm run all:build -``` -- Includes documentation injection -- Creates minified bundles -- Generates all npm packages -- Takes significantly longer (~15-30 minutes) - -**Build specific package:** -```bash -pnpm exec nx build devextreme -pnpm exec nx build devextreme-angular -pnpm exec nx build devextreme-react -pnpm exec nx build devextreme-vue -pnpm exec nx build devextreme-scss -pnpm exec nx build devextreme-themebuilder -``` - -**Build with Nx cache skip:** -```bash -pnpm exec nx build devextreme --skipNxCache -``` - -### DevExtreme Package Build Details - -**From packages/devextreme directory:** - -```bash -# Development build -pnpm run build:dev - -# Production build -pnpm run build - -# Build for TestCafe tests -pnpm run build:testcafe - -# Clean build artifacts -pnpm run clean -``` - -**Build process includes:** -1. Localization generation (via `devextreme-nx-infra-plugin:localization` executor) -2. Component generation (Renovation architecture) -3. Transpilation (via native NX executors: `babel-transform` for JS, `build-typescript` for TS) -4. Bundle creation (Webpack via `devextreme-nx-infra-plugin:bundle` executor) - `bundle:debug` and `bundle:prod` targets -5. TypeScript declarations - `build:declarations` target -6. SCSS compilation (from devextreme-scss) -7. NPM package preparation - `build:npm` target - -**Granular Nx build targets (can be run individually):** -```bash -pnpm exec nx build:localization devextreme # Generate localization files -pnpm exec nx build:transpile devextreme # Transpile source code -pnpm exec nx bundle:debug devextreme # Create debug bundle -pnpm exec nx bundle:prod devextreme # Create production bundle -pnpm exec nx build:vectormap devextreme # Build vectormap utils + region data -pnpm exec nx build:npm devextreme # Prepare NPM packages -``` - -**Build with testing configuration (for CI):** -```bash -pnpm exec nx build devextreme -c=testing -``` - -**Important environment variables:** -- `DEVEXTREME_TEST_CI=true` - Enables test mode (skips building npm package) -- `BUILD_ESM_PACKAGE=true` - Builds ESM modules (skips building npm package) -- `BUILD_TESTCAFE=true` - Builds for TestCafe tests -- `BUILD_TEST_INTERNAL_PACKAGE=true` - Builds internal test package - -## Custom Nx Executors (nx-infra-plugin) - -The `packages/nx-infra-plugin` provides custom Nx executors for build automation: - -| Executor | Description | -|----------|-------------| -| `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 | -| `bundle` | Bundles JavaScript files using webpack with debug or production mode, supporting multiple entry points and license validation | -| `clean` | Removes directories and files with support for exclusion patterns | -| `compress` | Minifies or beautifies JavaScript files, with optional debug block stripping | -| `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 | -| `vectormap` | Builds vectormap utility UMD bundles (`dx.vectormaputils.*.js`) and geographic region data modules from shapefile sources and JST templates | - -**Example executor usage in project.json:** -```json -{ - "build:localization:generate": { - "executor": "devextreme-nx-infra-plugin:localization", - "options": { - "messagesDir": "./js/localization/messages", - "cldrDataOutputDir": "./js/__internal/core/localization/cldr-data" - } - } -} -``` - -## Testing - -### Test Types and Commands - -**1. Lint (ALWAYS run before committing):** -```bash -# From root - runs lint on all packages -pnpm exec nx run-many -t lint,test --exclude devextreme devextreme-themebuilder devextreme-angular devextreme-react devextreme-vue devextreme-react-storybook devextreme-angular-playground devextreme-testcafe-tests devextreme-demos devextreme-react-playground devextreme-vue-playground - -# From packages/devextreme -pnpm run lint # All linting -pnpm run lint-js # JavaScript only -pnpm run lint-ts # TypeScript only -pnpm run lint-dts # .d.ts files only -pnpm run lint-texts # Non-Latin symbols validation -``` - -**2. Jest Tests (Unit tests):** -```bash -# From packages/devextreme -pnpm run test-jest # JSDOM tests only -pnpm run test-jest:node # Node tests only -pnpm run test-jest:all # Both JSDOM and Node tests -``` - -**3. QUnit Tests (Legacy unit tests):** -```bash -# Requires build first -pnpm exec nx build:dev devextreme - -# Run from packages/devextreme -pnpm run test-env # Launches test runner -``` - -**4. TestCafe Tests (E2E):** -```bash -# From e2e/testcafe-devextreme -pnpm exec nx run testcafe-devextreme:test -``` +# Test +pnpm nx run-many -t test # all packages +pnpm run test-jest # jest jsdom (from packages/devextreme) +pnpm run test-jest:all # jest jsdom + node +pnpm nx test devextreme-testcafe-tests # TestCafe e2e +pnpm nx test devextreme-angular # wrapper tests (also -react, -vue) -**5. Wrapper Tests:** -```bash -pnpm exec nx test devextreme-angular -pnpm exec nx test devextreme-react -pnpm exec nx test devextreme-vue -``` +# Lint +pnpm nx run-many -t lint # all packages +pnpm run lint # devextreme package: js, ts, dts, texts +pnpm run lint-js -- --fix # auto-fix JS -### Pre-commit Checks +# Regenerate (after changes to generators, TS declarations, or devextreme-internal-tools) +pnpm run regenerate-all +pnpm run update-ts-reexports # from packages/devextreme +pnpm run update-ts-bundle # from packages/devextreme -**Husky pre-commit hook runs:** -```bash -npm run lint-staged +# Clean +pnpm run clean # from packages/devextreme +pnpm nx clean:artifacts devextreme # build artifacts only ``` -**lint-staged configuration:** -- Root: `**/*.{css,scss}` → stylelint -- devextreme: `**/*.{js,ts,tsx}` → eslint --quiet - -## Validation Pipeline (CI Checks) +## Structure -### What Gets Checked on PRs - -**1. Default Workflow (`.github/workflows/default_workflow.yml`):** -- Runs `pnpm exec nx run-many -t lint,test` on most packages -- Timeout: 30 minutes -- Node: 20.x - -**2. Lint Workflow (`.github/workflows/lint.yml`):** -- **TS Lint:** TypeScript files, .d.ts files, TestCafe tests -- **JS Lint:** JavaScript files -- **Texts:** Non-Latin symbol validation -- **Component Exports:** Checks generated reexports are up-to-date -- **Wrappers:** Lints Angular, React, Vue wrappers -- Timeout: 60 minutes per job - -**3. Build All (`.github/workflows/build_all.yml`):** -- Runs `pnpm run all:build` -- Tests custom bundle creation -- Requires .NET 8.0.x -- Timeout: varies - -**4. Wrapper Tests (`.github/workflows/wrapper_tests.yml`):** -- Builds devextreme with `BUILD_TEST_INTERNAL_PACKAGE=true` -- Tests Angular (Ubuntu), React, Vue (devextreme-shr2) -- Checks wrapper regeneration is up-to-date -- Timeout: 20 minutes - -**5. QUnit Tests (`.github/workflows/qunit_tests.yml`):** -- Builds with `DEVEXTREME_TEST_CI=true` -- Runs tests in parallel across multiple constellations -- Timeout: 20 minutes - -**6. TestCafe Tests (`.github/workflows/testcafe_tests.yml`):** -- Accessibility tests across multiple themes -- Component-specific tests -- Timeout: varies by test suite - -### Common CI Failures and Fixes - -**"Generated code is outdated":** -```bash -# For wrappers -pnpm run regenerate-all - -# For component reexports (devextreme) -cd packages/devextreme -pnpm run update-ts-reexports ``` - -**"Lint errors":** -```bash -# Auto-fix where possible -pnpm run lint-js -- --fix -pnpm run lint-ts -- --fix +packages/ + devextreme/ # core library: ui/, viz/, core/, data/, renovation/ + devextreme-{angular,react,vue}/ # framework wrappers (generated) + devextreme-scss/ # SCSS themes + devextreme-themebuilder/ # theme builder + devextreme-metadata/ # metadata for wrapper generation + devextreme-monorepo-tools/ # internal tooling + nx-infra-plugin/ # custom Nx executors + workflows/ # cross-package Nx orchestration (all:build-dev, all:build-testing) + testcafe-models/ # TestCafe page object models +apps/ # demos and per-framework playgrounds (+ react-storybook) +e2e/testcafe-devextreme/ # TestCafe e2e suite +e2e/{wrappers,bundlers,compilation-cases}/ # integration / bundler / TS compilation tests +tools/scripts/ # build and utility scripts ``` -**"Build timeout":** -- Use `pnpm run all:build-dev` for faster builds during development -- CI uses caching for pnpm store and Nx cache - -## Making Changes - -### Workflow for Code Changes +For the full executor catalogue, conventions, and refactoring guidance, see @packages/nx-infra-plugin/AGENTS.md. File-specific coding rules live under @.github/instructions/. -1. **Install dependencies (if not done):** - ```bash - pnpm install --frozen-lockfile - ``` +## Build Pipeline -2. **Make your changes in appropriate package:** - - Core library: `packages/devextreme/js/` - - Styles: `packages/devextreme-scss/scss/` - - Wrappers: `packages/devextreme-{angular,react,vue}/src/` +localization → component generation (Renovation) → transpile (`babel-transform` for JS, `build-typescript` for TS) → bundle (Webpack via `devextreme-nx-infra-plugin:bundle`, debug + prod targets) → TypeScript declarations → SCSS compile (`devextreme-scss`) → npm package preparation. Task orchestration goes through Nx; cross-package builds (`all:build-dev`, `all:build`) live in the `workflows` package. Pre-commit hook runs `lint-staged` (stylelint + eslint --quiet). -3. **Build the affected package:** - ```bash - pnpm exec nx build:dev devextreme # For core changes - ``` +## Conventions -4. **Run tests:** - ```bash - pnpm run test-jest # Unit tests - pnpm run lint # Linting - ``` +**IMPORTANT:** All code contributions must follow the rules defined in @.github/instructions/. Before making any changes, check that directory for file-specific or pattern-specific coding conventions that apply to the files you're modifying. -5. **If you modified wrapper sources, regenerate:** - ```bash - pnpm run regenerate-all # Regenerates all wrappers - # OR specific wrapper: - pnpm run angular:regenerate - pnpm run react:regenerate - pnpm run vue:regenerate - ``` +- Use `pnpm nx ` rather than raw npm scripts so Nx caching and the dependency graph stay correct. +- Build before testing: `pnpm nx build:dev devextreme`; QUnit and TestCafe both require an up-to-date build. +- Run `pnpm run regenerate-all` after editing wrapper generators, TypeScript declarations, or `devextreme-internal-tools`. +- Edit source files only under `packages/devextreme/js/**`, `packages/devextreme-scss/scss/**`, and `packages/devextreme-metadata/**`. +- Match the Node and pnpm versions declared in `package.json` (`engines`, `packageManager`); mismatched versions cause CI failure. +- Set `DEVEXTREME_TEST_CI=true` for test-mode builds and `BUILD_TEST_INTERNAL_PACKAGE=true` for wrapper test prep. -6. **If you modified TypeScript declarations or devextreme-internal-tools:** - ```bash - cd packages/devextreme - pnpm run regenerate-all - pnpm run update-ts-reexports - pnpm run update-ts-bundle - ``` - -7. **Commit (pre-commit hook will run automatically):** - ```bash - git add . - git commit -m "Your message" - ``` - -### Common Pitfalls - -**✅ DO:** -- Always use `pnpm install --frozen-lockfile` -- Build before testing: `pnpm exec nx build:dev devextreme` -- Run `pnpm run regenerate-all` after modifying wrapper generators, TypeScript declarations, or devextreme-internal-tools (may affect code generators and/or metadata generators) -- Use Nx commands for better caching: `pnpm exec nx build devextreme` -- Check CI workflows to understand what will be validated - -### File Modification Guidelines - -**Generated files (DO NOT EDIT DIRECTLY):** -- `packages/devextreme-angular/src/**/*` (except templates) -- `packages/devextreme-react/src/**/*` (except templates) -- `packages/devextreme-vue/src/**/*` (except templates) -- `packages/devextreme/js/renovation/**/*.j.tsx` -- `packages/devextreme/js/__internal/core/localization/default_messages.ts` -- `packages/devextreme/js/__internal/core/localization/cldr-data/**/*` - -**Source files (EDIT THESE):** -- `packages/devextreme/js/**/*.js` (core logic) -- `packages/devextreme/js/**/*.ts` (TypeScript sources) -- `packages/devextreme-scss/scss/**/*.scss` (styles) -- `packages/devextreme-metadata/**/*` (metadata for wrappers) - -## Debugging Tips - -**Build issues:** -- Check Node version: `node --version` (should be 20.x) -- Check pnpm version: `pnpm --version` (should be 9.15.x) -- Clear Nx cache: `rm -rf .nx/cache` -- Clean and rebuild: `pnpm run clean && pnpm run build:dev` - -**Test failures:** -- Ensure build is up-to-date: `pnpm exec nx build:dev devextreme` -- Check if test requires specific environment variables -- Review test logs in `packages/devextreme/testing/` directory - -**Lint failures:** -- Run with `--fix` flag: `pnpm run lint-js -- --fix` -- Check `.eslintrc` or `eslint.config.mjs` for rules -- Verify file is not in ignore patterns - -## Key Facts - -- **Nx is used for task orchestration** - prefer `pnpm exec nx` commands over direct npm scripts -- **Custom Nx executors** - `devextreme-nx-infra-plugin` provides specialized executors for localization, file operations, and build tasks -- **Frozen lockfile is mandatory** - CI will fail without it -- **Build artifacts are in gitignore** - never commit `artifacts/` directories -- **Wrappers are generated** - modify generators, not generated code -- **Multiple test frameworks** - QUnit (legacy), Jest (new), TestCafe (E2E) -- **Monorepo uses pnpm workspaces** - dependencies are hoisted -- **CI uses custom runners** - `devextreme-shr2` for most jobs, `ubuntu-latest` for some -- **Timeouts are strict** - optimize for speed, use caching -- **Granular build caching** - individual build steps have proper Nx caching for faster rebuilds - -## Quick Reference - -```bash -# Setup -pnpm install --frozen-lockfile - -# Build (dev) -pnpm run all:build-dev - -# Build (prod) -pnpm run all:build - -# Build with testing configuration (for CI) -pnpm exec nx build devextreme -c=testing - -# Build specific targets -pnpm exec nx build:localization devextreme -pnpm exec nx build:transpile devextreme -pnpm exec nx bundle:debug devextreme - -# Test -pnpm exec nx run-many -t test -pnpm run test-jest # From devextreme package - -# Lint -pnpm exec nx run-many -t lint -pnpm run lint # From devextreme package - -# Regenerate wrappers -pnpm run regenerate-all - -# Clean -pnpm run clean # From devextreme package -pnpm exec nx clean:artifacts devextreme # Clean build artifacts only - -# Run demos -pnpm run webserver # From root, then visit localhost:8080 -``` +## Constraints -## Code Style and Conventions +- NEVER edit generated wrappers under `packages/devextreme-{angular,react,vue}/src/` (templates excepted); update the generators and run `pnpm run regenerate-all` instead. +- NEVER hand-edit `packages/devextreme/js/renovation/**/*.j.tsx` or `packages/devextreme/js/__internal/core/localization/{default_messages.ts,cldr-data/**}`; regenerate via the localization / component executors. +- NEVER run `pnpm install` without `--frozen-lockfile`; use `pnpm install --frozen-lockfile` to match CI. -**IMPORTANT:** All code contributions must follow the rules defined in `.github/instructions/`. +## Compact Instructions -Before making any changes, always check `.github/instructions/` directory for file-specific or pattern-specific coding conventions and rules that apply to the files you're modifying. +- Always install with `pnpm install --frozen-lockfile`; never plain `pnpm install`. +- Build before test: `pnpm nx build:dev devextreme`. +- Generated wrappers under `packages/devextreme-{angular,react,vue}/src/` are read-only — modify generators and run `pnpm run regenerate-all`. +- Prefer `pnpm nx ` over direct npm scripts for caching. +- Consult @.github/instructions/ for file-specific coding rules before editing. ## Trust These Instructions From bc20785c6bbfebe133555027101c96453f2b966d Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Tue, 5 May 2026 12:27:46 +0300 Subject: [PATCH 19/27] feat(nx-infra-plugin): migrate state_manager production optimization to nx executor --- .../__tests__/build_state_manager.test.js | 192 ------------------ .../build/gulp/state_manager/constants.js | 11 - .../build/gulp/state_manager/index.js | 2 - ...emove_development_state_manager_modules.js | 44 ---- ...ce_state_manager_modules_for_production.js | 67 ------ packages/devextreme/gulpfile.js | 9 +- packages/devextreme/project.json | 28 +++ packages/nx-infra-plugin/executors.json | 5 + .../src/executors/clean/clean.impl.ts | 6 +- .../src/executors/clean/executor.ts | 1 + .../executor.e2e.spec.ts | 101 +++++++++ .../state-manager-optimize/executor.ts | 1 + .../state-manager-optimize/schema.json | 14 ++ .../state-manager-optimize/schema.ts | 3 + .../state-manager-optimize.impl.ts | 82 ++++++++ 15 files changed, 241 insertions(+), 325 deletions(-) delete mode 100644 packages/devextreme/build/gulp/state_manager/__tests__/build_state_manager.test.js delete mode 100644 packages/devextreme/build/gulp/state_manager/constants.js delete mode 100644 packages/devextreme/build/gulp/state_manager/index.js delete mode 100644 packages/devextreme/build/gulp/state_manager/remove_development_state_manager_modules.js delete mode 100644 packages/devextreme/build/gulp/state_manager/replace_state_manager_modules_for_production.js create mode 100644 packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.e2e.spec.ts create mode 100644 packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.ts create mode 100644 packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.json create mode 100644 packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.ts create mode 100644 packages/nx-infra-plugin/src/executors/state-manager-optimize/state-manager-optimize.impl.ts diff --git a/packages/devextreme/build/gulp/state_manager/__tests__/build_state_manager.test.js b/packages/devextreme/build/gulp/state_manager/__tests__/build_state_manager.test.js deleted file mode 100644 index 4a86991225da..000000000000 --- a/packages/devextreme/build/gulp/state_manager/__tests__/build_state_manager.test.js +++ /dev/null @@ -1,192 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const through2 = require('through2'); -const Vinyl = require('vinyl'); -const replaceStateManagerModulesForProduction = require('../replace_state_manager_modules_for_production'); -const { removeDevelopmentStateManagerModules } = require('../remove_development_state_manager_modules'); - -const createEnvContent = (env) => ({ - index: [ - `export { setupStateManager } from './setup_state_manager';`, - `export { signal } from './reactive_primitives/index';` - ].join('\n'), - setupStateManager: `export const setupStateManager = () => { - // this setupStateManager function body is for ${env} build - }`, - reactivePrimitivesIndex: `export const signal = () => { - // this signal function body is for ${env} build - }`, -}); - -const PROD_DIR_CONTENT = createEnvContent('prod'); -const DEV_DIR_CONTENT = createEnvContent('dev'); - -const INDEX_DEV_CONTENT = `export { setupStateManager, signal } from './dev/index';`; -const INDEX_PROD_CONTENT = `export * from './prod/index';`; - -const FILE_OUTSIDE_OF_ENV_SPECIFIC_FOLDER_CONTENT = 'test content'; -const FILE_OUTSIDE_STATE_MANGER_CONTENT = 'console.log("file outside of state manager");'; - -describe('Build the state manager', () => { - let testsContext; - let originalConsoleError; - let consoleErrorSpy; - - const createEnvPaths = (baseDir, env) => ({ - reactivePrimitivesDir: path.join(baseDir, env, 'reactive_primitives'), - reactivePrimitivesIndex: path.join(baseDir, env, 'reactive_primitives', 'index.js'), - setupStateManager: path.join(baseDir, env, 'setup_state_manager.js'), - index: path.join(baseDir, env, 'index.js'), - }); - - const createEnvFiles = (paths, content) => { - Object.entries(paths).forEach(([key, filePath]) => { - if (filePath.endsWith('.js') && content[key]) { - fs.writeFileSync(filePath, content[key]); - } - }); - }; - - const createEnvSpecificStreamFileObjects = (paths, content) => { - return Object.entries(paths) - .filter(([key, filePath]) => filePath.endsWith('.js') && content[key]) - .map(([key, filePath]) => new Vinyl({ - path: filePath, - contents: Buffer.from(content[key]) - })); - }; - - beforeEach(() => { - const stream = replaceStateManagerModulesForProduction(); - const tempDir = path.join(__dirname, '__test-artifacts__'); - - if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - - const devextremeDir = path.join(tempDir, 'devextreme'); - const stateManagerDir = path.join(devextremeDir, 'esm', '__internal', 'core', 'state_manager'); - const cjsStateManagerDir = path.join(devextremeDir, 'cjs', '__internal', 'core', 'state_manager'); - const devDir = path.join(stateManagerDir, 'dev'); - const prodDir = path.join(stateManagerDir, 'prod'); - const cjsProdDir = path.join(cjsStateManagerDir, 'prod'); - - const devPaths = createEnvPaths(stateManagerDir, 'dev'); - const prodPaths = createEnvPaths(stateManagerDir, 'prod'); - const cjsProdPaths = createEnvPaths(cjsStateManagerDir, 'prod'); - - const indexFilePath = path.join(stateManagerDir, 'index.js'); - const cjsIndexFilePath = path.join(cjsStateManagerDir, 'index.js'); - const fileOutsideOfEnvSpecificFolderFilePath = path.join(stateManagerDir, 'state_manager.test.js'); - const fileOutsideStateMangerPath = path.join(tempDir, 'other_file.js'); - - fs.mkdirSync(stateManagerDir, { recursive: true }); - fs.mkdirSync(cjsStateManagerDir, { recursive: true }); - fs.mkdirSync(devDir, { recursive: true }); - fs.mkdirSync(prodDir, { recursive: true }); - fs.mkdirSync(cjsProdDir, { recursive: true }); - fs.mkdirSync(devPaths.reactivePrimitivesDir, { recursive: true }); - fs.mkdirSync(prodPaths.reactivePrimitivesDir, { recursive: true }); - fs.mkdirSync(cjsProdPaths.reactivePrimitivesDir, { recursive: true }); - - fs.writeFileSync(indexFilePath, INDEX_DEV_CONTENT); - fs.writeFileSync(cjsIndexFilePath, INDEX_DEV_CONTENT); - fs.writeFileSync(fileOutsideOfEnvSpecificFolderFilePath, FILE_OUTSIDE_OF_ENV_SPECIFIC_FOLDER_CONTENT); - fs.writeFileSync(fileOutsideStateMangerPath, FILE_OUTSIDE_STATE_MANGER_CONTENT); - - createEnvFiles(devPaths, DEV_DIR_CONTENT); - createEnvFiles(prodPaths, PROD_DIR_CONTENT); - createEnvFiles(cjsProdPaths, PROD_DIR_CONTENT); - - originalConsoleError = console.error; - consoleErrorSpy = jest.fn(); - console.error = consoleErrorSpy; - - const files = [ - ...createEnvSpecificStreamFileObjects(prodPaths, PROD_DIR_CONTENT), - ...createEnvSpecificStreamFileObjects(cjsProdPaths, PROD_DIR_CONTENT), - ...createEnvSpecificStreamFileObjects(devPaths, DEV_DIR_CONTENT), - new Vinyl({ - path: fileOutsideStateMangerPath, - contents: Buffer.from(FILE_OUTSIDE_STATE_MANGER_CONTENT) - }), - new Vinyl({ - path: fileOutsideOfEnvSpecificFolderFilePath, - contents: Buffer.from(FILE_OUTSIDE_OF_ENV_SPECIFIC_FOLDER_CONTENT) - }), - new Vinyl({ - path: indexFilePath, - contents: Buffer.from(INDEX_DEV_CONTENT) - }), - new Vinyl({ - path: cjsIndexFilePath, - contents: Buffer.from(INDEX_DEV_CONTENT) - }), - ]; - - stream.on('data', (file) => { - fs.writeFileSync(file.path, file.contents.toString()); - }); - - files.forEach(file => stream.write(file)); - stream.end(); - - testsContext = { - stream, - devextremeDir, - devDir, - prodPaths, - indexFilePath, - cjsIndexFilePath, - fileOutsideOfEnvSpecificFolderFilePath, - fileOutsideStateMangerPath - }; - }); - - afterEach(() => { - console.error = originalConsoleError; - }); - - const runTestWithStream = (testFn) => { - return (done) => { - testsContext.stream.on('end', () => { - try { - testFn(); - done(); - } catch (error) { - done(error); - } - }); - testsContext.stream.on('error', done); - }; - }; - - it('should remove development modules', runTestWithStream(() => { - removeDevelopmentStateManagerModules(testsContext.devextremeDir); - - expect(fs.existsSync(testsContext.devDir)).toBe(false); - expect(fs.existsSync(testsContext.fileOutsideOfEnvSpecificFolderFilePath)).toBe(false); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - })); - - it('should not remove modules that are unrelated to the state manager', runTestWithStream(() => { - removeDevelopmentStateManagerModules(testsContext.devextremeDir); - - const fileOutsideStateMangerPathContent = fs.readFileSync(testsContext.fileOutsideStateMangerPath, 'utf8'); - expect(fileOutsideStateMangerPathContent).toBe(FILE_OUTSIDE_STATE_MANGER_CONTENT); - })); - - it('should replace `index.js` content by `prod/index.js` content', runTestWithStream(() => { - removeDevelopmentStateManagerModules(testsContext.devextremeDir); - - const esmIndexContent = fs.readFileSync(testsContext.indexFilePath, 'utf8'); - expect(esmIndexContent).toBe(INDEX_PROD_CONTENT); - expect(fs.existsSync(testsContext.prodPaths.index)).toBe(true); - - const cjsIndexContent = fs.readFileSync(testsContext.cjsIndexFilePath, 'utf8'); - expect(cjsIndexContent).toContain('require("./prod/index")'); - expect(cjsIndexContent).not.toContain('export *'); - - expect(consoleErrorSpy).not.toHaveBeenCalled(); - })); -}); diff --git a/packages/devextreme/build/gulp/state_manager/constants.js b/packages/devextreme/build/gulp/state_manager/constants.js deleted file mode 100644 index b962f97ebd6f..000000000000 --- a/packages/devextreme/build/gulp/state_manager/constants.js +++ /dev/null @@ -1,11 +0,0 @@ -const path = require('path'); - -const STATE_MANAGER_FOLDER_PATH = path.join('__internal', 'core', 'state_manager'); -const STATE_MANAGER_INDEX_MODULE_PATH = path.join(STATE_MANAGER_FOLDER_PATH, 'index.js'); -const STATE_MANAGER_PROD_FOLDER_PATH = path.join(STATE_MANAGER_FOLDER_PATH, 'prod'); - -module.exports = { - STATE_MANAGER_FOLDER_PATH, - STATE_MANAGER_INDEX_MODULE_PATH, - STATE_MANAGER_PROD_FOLDER_PATH -}; diff --git a/packages/devextreme/build/gulp/state_manager/index.js b/packages/devextreme/build/gulp/state_manager/index.js deleted file mode 100644 index 40a8f4119714..000000000000 --- a/packages/devextreme/build/gulp/state_manager/index.js +++ /dev/null @@ -1,2 +0,0 @@ -require('./remove_development_state_manager_modules'); -require('./replace_state_manager_modules_for_production') diff --git a/packages/devextreme/build/gulp/state_manager/remove_development_state_manager_modules.js b/packages/devextreme/build/gulp/state_manager/remove_development_state_manager_modules.js deleted file mode 100644 index 0e59fab38ecc..000000000000 --- a/packages/devextreme/build/gulp/state_manager/remove_development_state_manager_modules.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict'; - -const path = require('path'); -const gulp = require('gulp'); -const del = require('del'); -const { - STATE_MANAGER_FOLDER_PATH, - STATE_MANAGER_INDEX_MODULE_PATH, - STATE_MANAGER_PROD_FOLDER_PATH -} = require('./constants'); -const ctx = require('../context'); - -const MODULE_TYPES = ['esm', 'cjs']; - -const removeDevelopmentStateManagerModules = (targetPath) => { - const patterns = []; - - MODULE_TYPES.forEach(type => { - patterns.push(`${path.join(targetPath, type, STATE_MANAGER_FOLDER_PATH)}/**`); - }); - - MODULE_TYPES.forEach(type => { - patterns.push(`!${path.join(targetPath, type, STATE_MANAGER_FOLDER_PATH)}`); - patterns.push(`!${path.join(targetPath, type, STATE_MANAGER_INDEX_MODULE_PATH)}`); - patterns.push(`!${path.join(targetPath, type, STATE_MANAGER_PROD_FOLDER_PATH)}`); - patterns.push(`!${path.join(targetPath, type, STATE_MANAGER_PROD_FOLDER_PATH)}/**`); - }); - - del.sync(patterns); -} - -const createRemoveDevelopmentStateManagerModulesTask = (targetPath) => (done) => { - removeDevelopmentStateManagerModules(targetPath); - done(); -}; - -gulp.task('state-manager-remove-development-only-modules-transpiled-prod-esm', createRemoveDevelopmentStateManagerModulesTask(ctx.TRANSPILED_PROD_ESM_PATH)); - -gulp.task('state-manager-remove-development-only-modules-transpiled-prod-renovation', createRemoveDevelopmentStateManagerModulesTask(ctx.TRANSPILED_PROD_RENOVATION_PATH)); - -module.exports = { - removeDevelopmentStateManagerModules, - createRemoveDevelopmentStateManagerModulesTask -}; diff --git a/packages/devextreme/build/gulp/state_manager/replace_state_manager_modules_for_production.js b/packages/devextreme/build/gulp/state_manager/replace_state_manager_modules_for_production.js deleted file mode 100644 index 6773a62184c1..000000000000 --- a/packages/devextreme/build/gulp/state_manager/replace_state_manager_modules_for_production.js +++ /dev/null @@ -1,67 +0,0 @@ -"use strict"; - -const gulp = require("gulp"); -const through2 = require("through2"); -const path = require("path"); -const babel = require("@babel/core"); -const { - STATE_MANAGER_FOLDER_PATH, - STATE_MANAGER_INDEX_MODULE_PATH, -} = require("./constants"); -const ctx = require("../context"); - -const ERROR_PREFIX = "Error during replacing the state manager modules:"; - -const ESM_REEXPORT = `export * from './prod/index';`; - -function isCjsFile(filePath) { - const normalizedPath = filePath.split(path.sep).join("/"); - return normalizedPath.includes("/cjs/"); -} - -function transpileToCjs(esmSource, filePath) { - const result = babel.transformSync(esmSource, { - filename: filePath, - plugins: [["@babel/plugin-transform-modules-commonjs"]], - }); - return result.code; -} - -function replaceStateManagerModulesForProduction() { - return through2.obj(function (file, enc, callback) { - if (file.path.includes(STATE_MANAGER_INDEX_MODULE_PATH)) { - try { - const content = isCjsFile(file.path) - ? transpileToCjs(ESM_REEXPORT, file.path) - : ESM_REEXPORT; - file.contents = Buffer.from(content); - } catch (error) { - callback(new Error(`${ERROR_PREFIX} ${error.message}`)); - return; - } - } - - callback(null, file); - }); -} - -const prepareStateManager = (dist) => - gulp.series.apply(gulp, [ - () => - gulp - .src(`${dist}/**/${STATE_MANAGER_FOLDER_PATH}/**`) - .pipe(replaceStateManagerModulesForProduction()) - .pipe(gulp.dest(dist)), - ]); - -gulp.task( - "state-manager-replace-production-modules-transpiled-prod-esm", - prepareStateManager(ctx.TRANSPILED_PROD_ESM_PATH) -); - -gulp.task( - "state-manager-replace-production-modules-transpiled-prod-renovation", - prepareStateManager(ctx.TRANSPILED_PROD_RENOVATION_PATH) -); - -module.exports = replaceStateManagerModulesForProduction; diff --git a/packages/devextreme/gulpfile.js b/packages/devextreme/gulpfile.js index fd1c9c1a6c71..046b978a2b46 100644 --- a/packages/devextreme/gulpfile.js +++ b/packages/devextreme/gulpfile.js @@ -35,7 +35,6 @@ require('./build/gulp/ts'); require('./build/gulp/localization'); require('./build/gulp/check_licenses'); require('./build/gulp/systemjs'); -require('./build/gulp/state_manager'); function getTranspileConfig() { if(env.TEST_CI) { @@ -71,6 +70,8 @@ gulp.task('aspnet', shell.task( gulp.task('vendor', shell.task('pnpm nx run devextreme:copy:vendor')); +gulp.task('state-manager-optimize', shell.task('pnpm nx run devextreme:state-manager:optimize')); + gulp.task('npm', shell.task( context.uglify ? 'pnpm nx run devextreme:build:npm -c production' @@ -107,11 +108,7 @@ function createDefaultBatch(dev) { tasks.push('transpile'); if(REMOVE_NON_PRODUCTION_MODULE) { - tasks.push('state-manager-replace-production-modules-transpiled-prod-renovation'); - tasks.push('state-manager-replace-production-modules-transpiled-prod-esm'); - - tasks.push('state-manager-remove-development-only-modules-transpiled-prod-renovation'); - tasks.push('state-manager-remove-development-only-modules-transpiled-prod-esm'); + tasks.push('state-manager-optimize'); } tasks.push(dev && !env.BUILD_TESTCAFE ? 'main-batch-dev' : 'main-batch'); diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 3dc196bffc35..54892c0f13e1 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -448,6 +448,23 @@ } } }, + "state-manager:optimize": { + "executor": "devextreme-nx-infra-plugin:state-manager-optimize", + "options": { + "transpiledDirs": [ + "./artifacts/transpiled-renovation-npm", + "./artifacts/transpiled-esm-npm" + ] + }, + "inputs": [ + "{projectRoot}/artifacts/transpiled-renovation-npm/{esm,cjs}/__internal/core/state_manager/**/*", + "{projectRoot}/artifacts/transpiled-esm-npm/{esm,cjs}/__internal/core/state_manager/**/*" + ], + "outputs": [ + "{projectRoot}/artifacts/transpiled-renovation-npm/{esm,cjs}/__internal/core/state_manager", + "{projectRoot}/artifacts/transpiled-esm-npm/{esm,cjs}/__internal/core/state_manager" + ] + }, "bundle:build": { "executor": "devextreme-nx-infra-plugin:bundle", "options": { @@ -1165,6 +1182,17 @@ "env": { "BUILD_TEST_INTERNAL_PACKAGE": "true" } + }, + "production": { + "commands": [ + "pnpm nx clean:artifacts devextreme", + "pnpm nx build:localization devextreme", + "pnpm nx build:transpile devextreme", + "pnpm nx state-manager:optimize devextreme", + "pnpm nx run-many --targets=bundle:debug,bundle:prod,build:vectormap,copy:vendor,build:aspnet,build:declarations --projects=devextreme --parallel -c production", + "pnpm nx build:npm devextreme", + "pnpm nx verify:licenses devextreme" + ] } } }, diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index 7c3b2919a7bf..ac649e9492ee 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -109,6 +109,11 @@ "implementation": "./src/executors/scss-assemble/executor", "schema": "./src/executors/scss-assemble/schema.json", "description": "Assemble SCSS package: copy files with data-uri inlining, fonts, and icons" + }, + "state-manager-optimize": { + "implementation": "./src/executors/state-manager-optimize/executor", + "schema": "./src/executors/state-manager-optimize/schema.json", + "description": "Optimize state_manager modules for production builds" } } } diff --git a/packages/nx-infra-plugin/src/executors/clean/clean.impl.ts b/packages/nx-infra-plugin/src/executors/clean/clean.impl.ts index 1025dcdb684f..c71e35c9c181 100644 --- a/packages/nx-infra-plugin/src/executors/clean/clean.impl.ts +++ b/packages/nx-infra-plugin/src/executors/clean/clean.impl.ts @@ -48,13 +48,13 @@ async function generateDeletionPlan(targetDir: string, excludePaths: string[]): return fullPaths.filter((fullPath) => !isPathExcluded(fullPath, excludePaths)); } -async function removeDirectoryCompletely(targetDirectory: string): Promise { +export async function removeDirectoryCompletely(targetDirectory: string): Promise { if (fs.existsSync(targetDirectory)) { await rimrafAsync(targetDirectory); } } -async function removeDirectoryWithExclusions( +export async function removeDirectoryRespectingExclusions( targetDirectory: string, excludePaths: string[], ): Promise { @@ -126,7 +126,7 @@ export default createExecutor({ return; } - await removeDirectoryWithExclusions(targetDirectory, absoluteExcludePaths); + await removeDirectoryRespectingExclusions(targetDirectory, absoluteExcludePaths); logger.verbose( `Cleaned directory: ${targetDirectory} with ${absoluteExcludePaths.length} exclusions preserved`, diff --git a/packages/nx-infra-plugin/src/executors/clean/executor.ts b/packages/nx-infra-plugin/src/executors/clean/executor.ts index 9452f56387d4..70d6b58434eb 100644 --- a/packages/nx-infra-plugin/src/executors/clean/executor.ts +++ b/packages/nx-infra-plugin/src/executors/clean/executor.ts @@ -1 +1,2 @@ export { default } from './clean.impl'; +export { removeDirectoryCompletely, removeDirectoryRespectingExclusions } from './clean.impl'; diff --git a/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.e2e.spec.ts new file mode 100644 index 000000000000..e0df6dad2f29 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.e2e.spec.ts @@ -0,0 +1,101 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import executor from './executor'; +import { StateManagerOptimizeExecutorSchema } from './schema'; +import { + createTempDir, + cleanupTempDir, + createMockContext, + findWorkspaceRoot, +} from '../../utils/test-utils'; +import { writeFileText, readFileText } from '../../utils'; + +const WORKSPACE_ROOT = findWorkspaceRoot(); + +const INDEX_DEV_CONTENT = `export { setupStateManager, signal } from './dev/index';\n`; +const INDEX_PROD_CONTENT = `export * from './prod/index';`; +const DEV_FILE_CONTENT = `export const devOnly = () => {};\n`; +const PROD_FILE_CONTENT = `export const prodOnly = () => {};\n`; +const TOP_LEVEL_NON_INDEX_CONTENT = `export const topLevelNonIndex = () => {};\n`; +const FILE_OUTSIDE_STATE_MANAGER_CONTENT = `console.log("file outside of state manager");`; + +const TRANSPILED_DIR = './artifacts/transpiled-test'; +const STATE_MANAGER_RELATIVE_PATH = path.join('__internal', 'core', 'state_manager'); + +const VARIANTS = ['esm', 'cjs'] as const; +type Variant = (typeof VARIANTS)[number]; + +describe('StateManagerOptimizeExecutor E2E', () => { + let tempDir: string; + let context = createMockContext(); + let projectDir: string; + let savedCwd: string; + + const stateManagerAbsoluteFor = (variant: Variant): string => + path.join(projectDir, TRANSPILED_DIR, variant, STATE_MANAGER_RELATIVE_PATH); + + const indexPathFor = (variant: Variant): string => + path.join(stateManagerAbsoluteFor(variant), 'index.js'); + + const runOptimize = async (): Promise => { + const options: StateManagerOptimizeExecutorSchema = { transpiledDirs: [TRANSPILED_DIR] }; + await executor(options, context); + }; + + beforeEach(async () => { + savedCwd = process.cwd(); + tempDir = createTempDir('nx-state-manager-optimize-e2e-'); + context = createMockContext({ root: tempDir }); + projectDir = path.join(tempDir, 'packages', 'test-lib'); + fs.mkdirSync(projectDir, { recursive: true }); + + const infraPluginNodeModules = path.join( + WORKSPACE_ROOT, + 'packages', + 'nx-infra-plugin', + 'node_modules', + ); + fs.symlinkSync(infraPluginNodeModules, path.join(projectDir, 'node_modules'), 'junction'); + + process.chdir(projectDir); + + for (const variant of VARIANTS) { + const dir = stateManagerAbsoluteFor(variant); + await writeFileText(indexPathFor(variant), INDEX_DEV_CONTENT); + await writeFileText(path.join(dir, 'state_manager.test.js'), TOP_LEVEL_NON_INDEX_CONTENT); + await writeFileText(path.join(dir, 'dev', 'index.js'), DEV_FILE_CONTENT); + await writeFileText(path.join(dir, 'prod', 'index.js'), PROD_FILE_CONTENT); + } + + await writeFileText(path.join(projectDir, 'other_file.js'), FILE_OUTSIDE_STATE_MANAGER_CONTENT); + }); + + afterEach(() => { + process.chdir(savedCwd); + cleanupTempDir(tempDir); + }); + + it('should remove development modules', async () => { + await runOptimize(); + + const esmDir = stateManagerAbsoluteFor('esm'); + expect(fs.existsSync(path.join(esmDir, 'dev'))).toBe(false); + expect(fs.existsSync(path.join(esmDir, 'state_manager.test.js'))).toBe(false); + }, 30000); + + it('should not remove modules unrelated to state_manager', async () => { + await runOptimize(); + + const outsidePath = path.join(projectDir, 'other_file.js'); + expect(await readFileText(outsidePath)).toBe(FILE_OUTSIDE_STATE_MANAGER_CONTENT); + }, 30000); + + it('should replace index.js content with re-export from prod', async () => { + await runOptimize(); + + expect(await readFileText(indexPathFor('esm'))).toBe(INDEX_PROD_CONTENT); + + const cjsIndexContent = await readFileText(indexPathFor('cjs')); + expect(cjsIndexContent).toContain('require("./prod/index")'); + }, 30000); +}); diff --git a/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.ts b/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.ts new file mode 100644 index 000000000000..cdb091451c3f --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.ts @@ -0,0 +1 @@ +export { default } from './state-manager-optimize.impl'; diff --git a/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.json b/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.json new file mode 100644 index 000000000000..1e89f6df6d25 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "transpiledDirs": { + "type": "array", + "description": "List of transpiled tree paths (relative to project root) whose state_manager modules should be optimized for production: replace each {esm,cjs}/__internal/core/state_manager/index.js with a re-export from prod/, then remove every entry inside state_manager/ except index.js and prod/.", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "required": ["transpiledDirs"] +} diff --git a/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.ts b/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.ts new file mode 100644 index 000000000000..60c2f3f6fc55 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.ts @@ -0,0 +1,3 @@ +export interface StateManagerOptimizeExecutorSchema { + transpiledDirs: string[]; +} diff --git a/packages/nx-infra-plugin/src/executors/state-manager-optimize/state-manager-optimize.impl.ts b/packages/nx-infra-plugin/src/executors/state-manager-optimize/state-manager-optimize.impl.ts new file mode 100644 index 000000000000..89b5e3b5d2c7 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/state-manager-optimize.impl.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import * as babel from '@babel/core'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { writeFileText } from '../../utils/file-operations'; +import { removeDirectoryRespectingExclusions } from '../clean/clean.impl'; +import { StateManagerOptimizeExecutorSchema } from './schema'; + +const ESM_REEXPORT = "export * from './prod/index';"; +const STATE_MANAGER_REL_PATH = path.join('__internal', 'core', 'state_manager'); +const ERROR_BABEL_NO_CODE = 'Babel returned no code for CJS state_manager index.js'; + +const VARIANTS = ['esm', 'cjs'] as const; +type Variant = (typeof VARIANTS)[number]; +type ContentBuilder = (indexPath: string) => string; + +function transformReexportToCjs(indexPath: string): string { + const result = babel.transformSync(ESM_REEXPORT, { + filename: indexPath, + plugins: [['@babel/plugin-transform-modules-commonjs']], + }); + + if (!result?.code) { + throw new Error(ERROR_BABEL_NO_CODE); + } + + return result.code; +} + +const CONTENT_BUILDERS: Record = { + esm: () => ESM_REEXPORT, + cjs: (indexPath) => transformReexportToCjs(indexPath), +}; + +async function optimizeVariant(variant: Variant, transpiledRoot: string): Promise { + const stateManagerDir = path.join(transpiledRoot, variant, STATE_MANAGER_REL_PATH); + + if (!fs.existsSync(stateManagerDir)) { + logger.verbose(`Skipping ${variant} state_manager: ${stateManagerDir} does not exist`); + return; + } + + const indexPath = path.join(stateManagerDir, 'index.js'); + if (fs.existsSync(indexPath)) { + await writeFileText(indexPath, CONTENT_BUILDERS[variant](indexPath)); + } + + await removeDirectoryRespectingExclusions(stateManagerDir, [ + indexPath, + path.join(stateManagerDir, 'prod'), + ]); +} + +async function optimizeTranspiledDir(transpiledDir: string, projectRoot: string): Promise { + const transpiledRoot = path.join(projectRoot, transpiledDir); + await Promise.all(VARIANTS.map((variant) => optimizeVariant(variant, transpiledRoot))); +} + +interface ResolvedStateManagerOptimize { + projectRoot: string; + transpiledDirs: string[]; +} + +export default createExecutor({ + name: 'StateManagerOptimize', + resolve: (options, { projectRoot }) => ({ + projectRoot, + transpiledDirs: options.transpiledDirs, + }), + run: async (resolved) => { + logger.verbose( + `Optimizing state_manager modules in ${resolved.transpiledDirs.length} transpiled tree(s)`, + ); + + await Promise.all( + resolved.transpiledDirs.map((transpiledDir) => + optimizeTranspiledDir(transpiledDir, resolved.projectRoot), + ), + ); + }, +}); From 47ea1d9408af31877087bfbed07025636cdc6dab Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Tue, 5 May 2026 12:28:40 +0300 Subject: [PATCH 20/27] feat(nx-infra-plugin): migrate verify:licenses to nx executor --- .../devextreme/build/gulp/check_licenses.js | 26 ------ packages/devextreme/gulpfile.js | 3 +- packages/devextreme/project.json | 19 ++++- packages/nx-infra-plugin/executors.json | 5 ++ .../license-check/executor.e2e.spec.ts | 84 +++++++++++++++++++ .../src/executors/license-check/executor.ts | 1 + .../license-check/license-check.impl.ts | 67 +++++++++++++++ .../src/executors/license-check/schema.json | 45 ++++++++++ .../src/executors/license-check/schema.ts | 12 +++ 9 files changed, 232 insertions(+), 30 deletions(-) delete mode 100644 packages/devextreme/build/gulp/check_licenses.js create mode 100644 packages/nx-infra-plugin/src/executors/license-check/executor.e2e.spec.ts create mode 100644 packages/nx-infra-plugin/src/executors/license-check/executor.ts create mode 100644 packages/nx-infra-plugin/src/executors/license-check/license-check.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/license-check/schema.json create mode 100644 packages/nx-infra-plugin/src/executors/license-check/schema.ts diff --git a/packages/devextreme/build/gulp/check_licenses.js b/packages/devextreme/build/gulp/check_licenses.js deleted file mode 100644 index 8928d5b960f2..000000000000 --- a/packages/devextreme/build/gulp/check_licenses.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict'; -const gulp = require('gulp'); -const lazyPipe = require('lazypipe'); -const named = require('vinyl-named'); - -const checkRruleLicenseNotice = lazyPipe() - .pipe(named, function(file) { - const name = 'rrule.js - Library for working with recurrence rules for calendar dates.'; - const url = 'https://github.com/jakubroztocil/rrule'; - const copyright = 'Copyright 2010, Jakub Roztocil and Lars Schoning'; - const licenseUrl = 'https://github.com/jakubroztocil/rrule/blob/master/LICENCE'; - const licenseType = 'Licenced under the BSD licence.'; - const separator = '\\s*\\*\\s'; - - const fileContent = file.contents.toString(); - const re = new RegExp(`\\* !\\s*.*${name}${separator}${url}${separator}*\\*\\s${copyright}${separator}${licenseType}${separator}${licenseUrl}`); - - if(fileContent.search(re) === -1) { - throw new Error(`RRule license header wasn't found in ${file.stem}`); - } - }); - -gulp.task('check-license-notices', function() { - return gulp.src('artifacts/js/dx.all.js') - .pipe(checkRruleLicenseNotice()); -}); diff --git a/packages/devextreme/gulpfile.js b/packages/devextreme/gulpfile.js index 046b978a2b46..0fbd9ddaad2d 100644 --- a/packages/devextreme/gulpfile.js +++ b/packages/devextreme/gulpfile.js @@ -33,7 +33,6 @@ require('./build/gulp/transpile'); require('./build/gulp/js-bundles'); require('./build/gulp/ts'); require('./build/gulp/localization'); -require('./build/gulp/check_licenses'); require('./build/gulp/systemjs'); function getTranspileConfig() { @@ -70,6 +69,8 @@ gulp.task('aspnet', shell.task( gulp.task('vendor', shell.task('pnpm nx run devextreme:copy:vendor')); +gulp.task('check-license-notices', shell.task('pnpm nx run devextreme:verify:licenses')); + gulp.task('state-manager-optimize', shell.task('pnpm nx run devextreme:state-manager:optimize')); gulp.task('npm', shell.task( diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 54892c0f13e1..70bddf0f88bd 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -957,10 +957,23 @@ ] }, "verify:licenses": { - "executor": "nx:run-commands", + "executor": "devextreme-nx-infra-plugin:license-check", + "inputs": [ + "{projectRoot}/artifacts/js/dx.all.js" + ], "options": { - "command": "gulp check-license-notices", - "cwd": "{projectRoot}" + "files": [ + "./artifacts/js/dx.all.js" + ], + "licenses": [ + { + "name": "rrule.js - Library for working with recurrence rules for calendar dates.", + "homepageUrl": "https://github.com/jakubroztocil/rrule", + "copyright": "Copyright 2010, Jakub Roztocil and Lars Schoning", + "licenseType": "Licenced under the BSD licence.", + "licenseUrl": "https://github.com/jakubroztocil/rrule/blob/master/LICENCE" + } + ] } }, "copy:vendor:js": { diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index ac649e9492ee..8672e3559e1c 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -110,6 +110,11 @@ "schema": "./src/executors/scss-assemble/schema.json", "description": "Assemble SCSS package: copy files with data-uri inlining, fonts, and icons" }, + "license-check": { + "implementation": "./src/executors/license-check/executor", + "schema": "./src/executors/license-check/schema.json", + "description": "Verify embedded license notices in built artifacts" + }, "state-manager-optimize": { "implementation": "./src/executors/state-manager-optimize/executor", "schema": "./src/executors/state-manager-optimize/schema.json", diff --git a/packages/nx-infra-plugin/src/executors/license-check/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/license-check/executor.e2e.spec.ts new file mode 100644 index 000000000000..d1fdc4ef7ba7 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/license-check/executor.e2e.spec.ts @@ -0,0 +1,84 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import executor from './executor'; +import { LicenseCheckExecutorSchema } from './schema'; +import { createTempDir, cleanupTempDir, createMockContext } from '../../utils/test-utils'; +import { writeFileText } from '../../utils'; + +const TEST_NOTICE = `/* ! + * synthetic-lib.js - Library for verifying the license-check executor. + * https://example.com/synthetic-lib + * + * Copyright 2026, Synthetic Author + * Licensed under the Test license. + * https://example.com/synthetic-lib/LICENSE + * + */ +`; + +const TEST_LICENSE: LicenseCheckExecutorSchema['licenses'][number] = { + name: 'synthetic-lib.js - Library for verifying the license-check executor.', + homepageUrl: 'https://example.com/synthetic-lib', + copyright: 'Copyright 2026, Synthetic Author', + licenseType: 'Licensed under the Test license.', + licenseUrl: 'https://example.com/synthetic-lib/LICENSE', +}; + +describe('LicenseCheckExecutor E2E', () => { + let tempDir: string; + let context = createMockContext(); + let projectDir: string; + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + tempDir = createTempDir('nx-license-check-e2e-'); + context = createMockContext({ root: tempDir }); + projectDir = path.join(tempDir, 'packages', 'test-lib'); + fs.mkdirSync(projectDir, { recursive: true }); + errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + errorSpy.mockRestore(); + cleanupTempDir(tempDir); + }); + + it('should succeed when configured license notices are present in the bundle', async () => { + const filePath = path.join(projectDir, 'bundle.js'); + await writeFileText(filePath, `// prologue\n${TEST_NOTICE}// epilogue\n`); + + const result = await executor({ files: ['./bundle.js'], licenses: [TEST_LICENSE] }, context); + + expect(result.success).toBe(true); + }); + + it('should fail and report the missing license name when a notice is absent', async () => { + const filePath = path.join(projectDir, 'bundle.js'); + await writeFileText(filePath, '// no notice here\n'); + + const result = await executor({ files: ['./bundle.js'], licenses: [TEST_LICENSE] }, context); + + expect(result.success).toBe(false); + const reportedMessage = String(errorSpy.mock.calls[0][0]); + expect(reportedMessage).toContain(TEST_LICENSE.name); + expect(reportedMessage).toContain('bundle.js'); + }); + + it('should aggregate misses across multiple files and only list failing files', async () => { + const passingPath = path.join(projectDir, 'bundle-a.js'); + const failingPath = path.join(projectDir, 'bundle-b.js'); + await writeFileText(passingPath, `${TEST_NOTICE}`); + await writeFileText(failingPath, '// missing notice\n'); + + const result = await executor( + { files: ['./bundle-a.js', './bundle-b.js'], licenses: [TEST_LICENSE] }, + context, + ); + + expect(result.success).toBe(false); + const reportedMessage = String(errorSpy.mock.calls[0][0]); + expect(reportedMessage).toContain('bundle-b.js'); + expect(reportedMessage).not.toContain('bundle-a.js'); + }); +}); diff --git a/packages/nx-infra-plugin/src/executors/license-check/executor.ts b/packages/nx-infra-plugin/src/executors/license-check/executor.ts new file mode 100644 index 000000000000..5157c28c91c2 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/license-check/executor.ts @@ -0,0 +1 @@ +export { default } from './license-check.impl'; diff --git a/packages/nx-infra-plugin/src/executors/license-check/license-check.impl.ts b/packages/nx-infra-plugin/src/executors/license-check/license-check.impl.ts new file mode 100644 index 000000000000..7b449d48b130 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/license-check/license-check.impl.ts @@ -0,0 +1,67 @@ +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { readFileText } from '../../utils/file-operations'; +import { LicenseCheckExecutorSchema, LicenseEntry } from './schema'; + +interface LicenseMiss { + file: string; + licenseName: string; +} + +interface ResolvedLicenseCheck { + projectRoot: string; + files: { absolutePath: string; displayPath: string }[]; + licenses: LicenseEntry[]; +} + +const LICENSE_FIELD_SEPARATOR = '\\s*\\*\\s'; +const ERROR_HEADER_SINGLE = 'issue'; +const ERROR_HEADER_PLURAL = 'issues'; + +export function buildLicenseRegex(entry: LicenseEntry): RegExp { + const pattern = + `\\* !\\s*.*${entry.name}${LICENSE_FIELD_SEPARATOR}` + + `${entry.homepageUrl}${LICENSE_FIELD_SEPARATOR}*\\*\\s` + + `${entry.copyright}${LICENSE_FIELD_SEPARATOR}` + + `${entry.licenseType}${LICENSE_FIELD_SEPARATOR}` + + `${entry.licenseUrl}`; + return new RegExp(pattern); +} + +function buildErrorMessage(misses: LicenseMiss[]): string { + const label = misses.length === 1 ? ERROR_HEADER_SINGLE : ERROR_HEADER_PLURAL; + const header = `License notice check failed (${misses.length} ${label}):`; + const bullets = misses + .map((miss) => ` - "${miss.licenseName}" not found in ${miss.file}`) + .join('\n'); + return `${header}\n${bullets}`; +} + +export default createExecutor({ + name: 'LicenseCheck', + resolve: (options, { projectRoot }) => { + const files = options.files.map((fileEntry) => { + const absolutePath = path.resolve(projectRoot, fileEntry); + const displayPath = path.relative(projectRoot, absolutePath) || absolutePath; + return { absolutePath, displayPath }; + }); + return { projectRoot, files, licenses: options.licenses }; + }, + run: async (resolved) => { + const misses: LicenseMiss[] = []; + for (const fileEntry of resolved.files) { + const fileContent = await readFileText(fileEntry.absolutePath); + for (const licenseEntry of resolved.licenses) { + const licenseRegex = buildLicenseRegex(licenseEntry); + if (fileContent.search(licenseRegex) === -1) { + misses.push({ file: fileEntry.displayPath, licenseName: licenseEntry.name }); + } + } + logger.verbose(`Checked license notices in ${fileEntry.displayPath}`); + } + if (misses.length > 0) { + throw new Error(buildErrorMessage(misses)); + } + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/license-check/schema.json b/packages/nx-infra-plugin/src/executors/license-check/schema.json new file mode 100644 index 000000000000..157dc77562d9 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/license-check/schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/schema", + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "Paths to files whose embedded license notices must be verified (relative to project root)" + }, + "licenses": { + "type": "array", + "minItems": 1, + "description": "License entries to verify; each entry is converted to a regex and searched in every file", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable library description; appears in error messages and is matched verbatim in the bundle" + }, + "homepageUrl": { + "type": "string", + "description": "Library homepage URL embedded in the license notice" + }, + "copyright": { + "type": "string", + "description": "Copyright line embedded in the license notice" + }, + "licenseType": { + "type": "string", + "description": "License-type line embedded in the license notice" + }, + "licenseUrl": { + "type": "string", + "description": "License-text URL embedded in the license notice" + } + }, + "required": ["name", "homepageUrl", "copyright", "licenseType", "licenseUrl"], + "additionalProperties": false + } + } + }, + "required": ["files", "licenses"] +} diff --git a/packages/nx-infra-plugin/src/executors/license-check/schema.ts b/packages/nx-infra-plugin/src/executors/license-check/schema.ts new file mode 100644 index 000000000000..5389bf48b993 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/license-check/schema.ts @@ -0,0 +1,12 @@ +export interface LicenseEntry { + name: string; + homepageUrl: string; + copyright: string; + licenseType: string; + licenseUrl: string; +} + +export interface LicenseCheckExecutorSchema { + files: string[]; + licenses: LicenseEntry[]; +} From 0ab3e1ca80dfe8cc55bc0b99401e605dbd7cd8ef Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Tue, 5 May 2026 13:12:00 +0300 Subject: [PATCH 21/27] chore(nx-infra-plugin): standardize executor schema.json to canonical Nx format --- .../executors/add-license-headers/schema.json | 9 ++-- .../src/executors/babel-transform/schema.json | 18 +++++--- .../build-angular-library/schema.json | 11 ++--- .../executors/build-typescript/schema.json | 15 +++---- .../src/executors/bundle/schema.json | 42 +++++++++++++++---- .../src/executors/clean/schema.json | 6 ++- .../src/executors/compress/schema.json | 2 + .../executors/concatenate-files/schema.json | 3 +- .../src/executors/copy-files/schema.json | 4 +- .../create-dual-mode-manifest/schema.json | 14 ++++--- .../src/executors/dts-bundle/schema.json | 3 +- .../src/executors/dts-modules/schema.json | 3 +- .../generate-component-names/schema.json | 9 ++-- .../executors/generate-components/schema.json | 3 ++ .../src/executors/karma-multi-env/schema.json | 5 ++- .../src/executors/license-check/schema.json | 2 + .../src/executors/localization/schema.json | 9 ++-- .../src/executors/npm-assemble/schema.json | 3 +- .../src/executors/pack-npm/schema.json | 6 ++- .../prepare-package-json/schema.json | 6 ++- .../executors/prepare-submodules/schema.json | 7 ++-- .../src/executors/scss-assemble/schema.json | 3 +- .../state-manager-optimize/schema.json | 3 ++ .../src/executors/vectormap/schema.json | 2 + 24 files changed, 125 insertions(+), 63 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json index e99b8d3c8f67..7a5642b1d58c 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.json @@ -1,7 +1,7 @@ { - "$schema": "https://json-schema.org/draft-07/schema", - "title": "Add License Headers Executor", - "description": "Add license headers to compiled files with support for custom templates", + "$schema": "https://json-schema.org/schema", + "title": "Add License Headers Executor Schema", + "description": "Add license headers to compiled files", "type": "object", "properties": { "targetDirectory": { @@ -62,6 +62,5 @@ "enum": ["eula", "mit"], "description": "Selects which bundled license template to use. 'eula' references the DevExtreme EULA URL; 'mit' references MIT terms and a GitHub repo URL. When licenseTemplateFile is provided, mode is ignored." } - }, - "required": [] + } } diff --git a/packages/nx-infra-plugin/src/executors/babel-transform/schema.json b/packages/nx-infra-plugin/src/executors/babel-transform/schema.json index 994099d6d110..79f2256d12e7 100644 --- a/packages/nx-infra-plugin/src/executors/babel-transform/schema.json +++ b/packages/nx-infra-plugin/src/executors/babel-transform/schema.json @@ -1,7 +1,8 @@ { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", + "title": "Babel Transform Executor Schema", + "description": "Transform JavaScript/TypeScript files using Babel with configurable presets", "type": "object", - "title": "Babel Transform Executor", "properties": { "babelConfigPath": { "type": "string", @@ -53,10 +54,17 @@ "description": "Destination path relative to outDir." } }, - "required": ["from", "to"] + "required": [ + "from", + "to" + ] } } }, - "required": ["babelConfigPath", "configKey", "sourcePattern", "outDir"], - "additionalProperties": false + "required": [ + "babelConfigPath", + "configKey", + "sourcePattern", + "outDir" + ] } diff --git a/packages/nx-infra-plugin/src/executors/build-angular-library/schema.json b/packages/nx-infra-plugin/src/executors/build-angular-library/schema.json index 5af7b2656584..91f82c0f182f 100644 --- a/packages/nx-infra-plugin/src/executors/build-angular-library/schema.json +++ b/packages/nx-infra-plugin/src/executors/build-angular-library/schema.json @@ -1,8 +1,8 @@ { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", + "title": "Build Angular Library Executor Schema", + "description": "Build Angular libraries using ng-packagr", "type": "object", - "title": "Build Angular Library Executor", - "description": "Build Angular libraries using ng-packagr programmatically. This executor invokes ng-packagr to compile Angular components, generate metadata, and package the library for distribution.", "properties": { "project": { "type": "string", @@ -18,6 +18,7 @@ "description": "Output directory path relative to project root where the built library will be written. Contains compiled JavaScript, type definitions, and package metadata. If not specified, uses the 'dest' value from ng-package.json configuration." } }, - "required": ["project"], - "additionalProperties": false + "required": [ + "project" + ] } 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 69d9b88102ad..41c9036fcdf4 100644 --- a/packages/nx-infra-plugin/src/executors/build-typescript/schema.json +++ b/packages/nx-infra-plugin/src/executors/build-typescript/schema.json @@ -1,13 +1,16 @@ { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", + "title": "Build Typescript Executor Schema", + "description": "Build TypeScript modules (CJS or ESM) with configurable output format", "type": "object", - "title": "Build TypeScript Executor", - "description": "Compile TypeScript code to CommonJS or ESM modules", "properties": { "module": { "type": "string", "description": "Target module format", - "enum": ["cjs", "esm"], + "enum": [ + "cjs", + "esm" + ], "default": "esm" }, "srcPattern": { @@ -41,7 +44,5 @@ "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": [], - "additionalProperties": false + } } diff --git a/packages/nx-infra-plugin/src/executors/bundle/schema.json b/packages/nx-infra-plugin/src/executors/bundle/schema.json index 4b19e9742c68..a94a4bd268c1 100644 --- a/packages/nx-infra-plugin/src/executors/bundle/schema.json +++ b/packages/nx-infra-plugin/src/executors/bundle/schema.json @@ -1,10 +1,14 @@ { "$schema": "https://json-schema.org/schema", + "title": "Bundle Executor Schema", + "description": "Bundle JavaScript files using webpack with debug or production mode", "type": "object", "properties": { "entries": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Bundle entry point paths relative to sourceDir" }, "sourceDir": { @@ -17,7 +21,10 @@ }, "mode": { "type": "string", - "enum": ["debug", "production"], + "enum": [ + "debug", + "production" + ], "description": "Bundle mode" }, "webpackConfigPath": { @@ -41,7 +48,10 @@ }, "mode": { "type": "string", - "enum": ["eula", "mit"], + "enum": [ + "eula", + "mit" + ], "description": "Selects which bundled license template to use when licenseTemplateFile is omitted" }, "eulaUrl": { @@ -54,7 +64,10 @@ }, "commentType": { "type": "string", - "enum": ["!", "*"], + "enum": [ + "!", + "*" + ], "description": "Comment marker placed after /* in the banner opening" }, "separator": { @@ -67,22 +80,33 @@ }, "filenameMode": { "type": "string", - "enum": ["relative", "basename"], + "enum": [ + "relative", + "basename" + ], "description": "Filename token used in the banner template" }, "includePatterns": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Glob patterns of files within outDir to add headers to" }, "excludePatterns": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Glob patterns of files within outDir to skip" } } } }, - "required": ["entries", "sourceDir", "outDir", "mode"], - "additionalProperties": false + "required": [ + "entries", + "sourceDir", + "outDir", + "mode" + ] } diff --git a/packages/nx-infra-plugin/src/executors/clean/schema.json b/packages/nx-infra-plugin/src/executors/clean/schema.json index 4e9079727919..14d18402d040 100644 --- a/packages/nx-infra-plugin/src/executors/clean/schema.json +++ b/packages/nx-infra-plugin/src/executors/clean/schema.json @@ -1,4 +1,7 @@ { + "$schema": "https://json-schema.org/schema", + "title": "Clean Executor Schema", + "description": "Clean directories with support for simple or recursive mode", "type": "object", "properties": { "targetDirectory": { @@ -14,6 +17,5 @@ }, "default": [] } - }, - "required": [] + } } diff --git a/packages/nx-infra-plugin/src/executors/compress/schema.json b/packages/nx-infra-plugin/src/executors/compress/schema.json index 31bc8273e527..274d2e47b392 100644 --- a/packages/nx-infra-plugin/src/executors/compress/schema.json +++ b/packages/nx-infra-plugin/src/executors/compress/schema.json @@ -1,5 +1,7 @@ { "$schema": "https://json-schema.org/schema", + "title": "Compress Executor Schema", + "description": "Compress JavaScript files", "type": "object", "properties": { "files": { diff --git a/packages/nx-infra-plugin/src/executors/concatenate-files/schema.json b/packages/nx-infra-plugin/src/executors/concatenate-files/schema.json index 0bea5d738af6..878245ad338a 100644 --- a/packages/nx-infra-plugin/src/executors/concatenate-files/schema.json +++ b/packages/nx-infra-plugin/src/executors/concatenate-files/schema.json @@ -1,7 +1,8 @@ { "$schema": "https://json-schema.org/schema", + "title": "Concatenate Files Executor Schema", + "description": "Concatenate files with optional content extraction and transforms", "type": "object", - "description": "Concatenate files with optional content extraction and transforms.", "properties": { "sourceFiles": { "type": "array", diff --git a/packages/nx-infra-plugin/src/executors/copy-files/schema.json b/packages/nx-infra-plugin/src/executors/copy-files/schema.json index 144a1b285a2f..dbb2e1e4ad6b 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/schema.json +++ b/packages/nx-infra-plugin/src/executors/copy-files/schema.json @@ -1,5 +1,7 @@ { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", + "title": "Copy Files Executor Schema", + "description": "Copy files to destination", "type": "object", "properties": { "files": { 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 index 333abe0493c0..6582e3579410 100644 --- 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 @@ -1,8 +1,8 @@ { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", + "title": "Create Dual Mode Manifest Executor Schema", + "description": "Generate package.json files for dual-mode (ESM + CJS) package support", "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", @@ -35,6 +35,10 @@ "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 + "required": [ + "esmDir", + "cjsDir", + "outputDir", + "srcDir" + ] } diff --git a/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json index de2cf27fcd29..abee1ba68473 100644 --- a/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json @@ -1,7 +1,8 @@ { "$schema": "https://json-schema.org/schema", + "title": "Dts Bundle Executor Schema", + "description": "Assemble TypeScript declaration bundle files from source", "type": "object", - "description": "Assemble TypeScript declaration bundle files from source.", "properties": { "bundleSources": { "type": "array", diff --git a/packages/nx-infra-plugin/src/executors/dts-modules/schema.json b/packages/nx-infra-plugin/src/executors/dts-modules/schema.json index 7797b44583e7..7fe591b0c638 100644 --- a/packages/nx-infra-plugin/src/executors/dts-modules/schema.json +++ b/packages/nx-infra-plugin/src/executors/dts-modules/schema.json @@ -1,7 +1,8 @@ { "$schema": "https://json-schema.org/schema", + "title": "Dts Modules Executor Schema", + "description": "Assemble TypeScript declaration modules: copy, stamp with license, and strip debug blocks", "type": "object", - "description": "Assemble TypeScript declaration modules: copy, stamp with license, and strip debug blocks.", "properties": { "sourceDir": { "type": "string", diff --git a/packages/nx-infra-plugin/src/executors/generate-component-names/schema.json b/packages/nx-infra-plugin/src/executors/generate-component-names/schema.json index 96666c143d3d..e66eab8827f6 100644 --- a/packages/nx-infra-plugin/src/executors/generate-component-names/schema.json +++ b/packages/nx-infra-plugin/src/executors/generate-component-names/schema.json @@ -1,7 +1,8 @@ { - "$schema": "http://json-schema.org/draft-07/schema", + "$schema": "https://json-schema.org/schema", + "title": "Generate Component Names Executor Schema", + "description": "Generate component names", "type": "object", - "description": "Generate component names. Use case example: server-side testing", "properties": { "componentFilesPath": { "type": "string", @@ -21,7 +22,5 @@ "description": "Output file path for generated component names", "default": "./tests/src/server/component-names.ts" } - }, - "required": [], - "additionalProperties": false + } } diff --git a/packages/nx-infra-plugin/src/executors/generate-components/schema.json b/packages/nx-infra-plugin/src/executors/generate-components/schema.json index 88a3b54a1d20..8c789414f5e6 100644 --- a/packages/nx-infra-plugin/src/executors/generate-components/schema.json +++ b/packages/nx-infra-plugin/src/executors/generate-components/schema.json @@ -1,4 +1,7 @@ { + "$schema": "https://json-schema.org/schema", + "title": "Generate Components Executor Schema", + "description": "Generate framework components from DevExtreme metadata", "type": "object", "properties": { "metadataPath": { diff --git a/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.json b/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.json index 797b075b5a16..869b099e6eee 100644 --- a/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.json +++ b/packages/nx-infra-plugin/src/executors/karma-multi-env/schema.json @@ -1,7 +1,8 @@ { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", + "title": "Karma Multi Env Executor Schema", + "description": "Run Karma tests sequentially across multiple Angular environments (client, server, hydration)", "type": "object", - "title": "Karma Multi-Environment Test Executor", "properties": { "karmaConfig": { "type": "string", diff --git a/packages/nx-infra-plugin/src/executors/license-check/schema.json b/packages/nx-infra-plugin/src/executors/license-check/schema.json index 157dc77562d9..100ea6fa01d8 100644 --- a/packages/nx-infra-plugin/src/executors/license-check/schema.json +++ b/packages/nx-infra-plugin/src/executors/license-check/schema.json @@ -1,5 +1,7 @@ { "$schema": "https://json-schema.org/schema", + "title": "License Check Executor Schema", + "description": "Verify embedded license notices in built artifacts", "type": "object", "properties": { "files": { diff --git a/packages/nx-infra-plugin/src/executors/localization/schema.json b/packages/nx-infra-plugin/src/executors/localization/schema.json index 9b98f25a7e46..a0a4670991d2 100644 --- a/packages/nx-infra-plugin/src/executors/localization/schema.json +++ b/packages/nx-infra-plugin/src/executors/localization/schema.json @@ -1,7 +1,7 @@ { - "$schema": "https://json-schema.org/draft-07/schema", - "title": "Localization Executor", - "description": "Generates localization message files and TypeScript CLDR data modules", + "$schema": "https://json-schema.org/schema", + "title": "Localization Executor Schema", + "description": "Generate localization message files and TypeScript CLDR data modules", "type": "object", "properties": { "messagesDir": { @@ -66,6 +66,5 @@ "targetSubdir": { "type": "string" } } } - }, - "required": [] + } } diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json index 2ff79ad68190..47619acdc379 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json @@ -1,7 +1,8 @@ { "$schema": "https://json-schema.org/schema", + "title": "Npm Assemble Executor Schema", + "description": "Assemble npm package from transpiled sources, bin, license, dist files and meta", "type": "object", - "description": "Assemble npm package from transpiled sources, bin, license, dist files and meta.", "properties": { "transpiledDir": { "type": "string", diff --git a/packages/nx-infra-plugin/src/executors/pack-npm/schema.json b/packages/nx-infra-plugin/src/executors/pack-npm/schema.json index 888e8312ae4f..ae5723c71a34 100644 --- a/packages/nx-infra-plugin/src/executors/pack-npm/schema.json +++ b/packages/nx-infra-plugin/src/executors/pack-npm/schema.json @@ -1,4 +1,7 @@ { + "$schema": "https://json-schema.org/schema", + "title": "Pack Npm Executor Schema", + "description": "Run pnpm pack for npm distribution", "type": "object", "properties": { "workingDirectory": { @@ -6,6 +9,5 @@ "description": "Working directory for pnpm pack", "default": "./" } - }, - "required": [] + } } diff --git a/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.json b/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.json index 308f88720d90..b5084cb53f02 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.json +++ b/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.json @@ -1,4 +1,7 @@ { + "$schema": "https://json-schema.org/schema", + "title": "Prepare Package Json Executor Schema", + "description": "Create npm distribution package.json", "type": "object", "properties": { "sourcePackageJson": { @@ -50,6 +53,5 @@ }, "required": ["find", "replace"] } - }, - "required": [] + } } diff --git a/packages/nx-infra-plugin/src/executors/prepare-submodules/schema.json b/packages/nx-infra-plugin/src/executors/prepare-submodules/schema.json index acbe9f3475c3..108162b070aa 100644 --- a/packages/nx-infra-plugin/src/executors/prepare-submodules/schema.json +++ b/packages/nx-infra-plugin/src/executors/prepare-submodules/schema.json @@ -1,4 +1,7 @@ { + "$schema": "https://json-schema.org/schema", + "title": "Prepare Submodules Executor Schema", + "description": "Prepare submodule entry points with package.json files", "type": "object", "properties": { "distDirectory": { @@ -6,7 +9,5 @@ "description": "Distribution directory containing ESM and CJS builds. This directory will be scanned to generate submodule package.json files.", "default": "./npm" } - }, - "additionalProperties": true, - "required": [] + } } diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json index b203ebd60789..3efa0803ccff 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json @@ -1,7 +1,8 @@ { "$schema": "https://json-schema.org/schema", + "title": "Scss Assemble Executor Schema", + "description": "Assemble SCSS package: copy files with data-uri inlining, fonts, and icons", "type": "object", - "description": "Assemble SCSS package: copy files with data-uri inlining, fonts, and icons.", "properties": { "scssPackagePath": { "type": "string", diff --git a/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.json b/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.json index 1e89f6df6d25..aa1084c7ae49 100644 --- a/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.json +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.json @@ -1,4 +1,7 @@ { + "$schema": "https://json-schema.org/schema", + "title": "State Manager Optimize Executor Schema", + "description": "Optimize state_manager modules for production builds", "type": "object", "properties": { "transpiledDirs": { diff --git a/packages/nx-infra-plugin/src/executors/vectormap/schema.json b/packages/nx-infra-plugin/src/executors/vectormap/schema.json index 6da755653cff..4503f6c9c2be 100644 --- a/packages/nx-infra-plugin/src/executors/vectormap/schema.json +++ b/packages/nx-infra-plugin/src/executors/vectormap/schema.json @@ -1,5 +1,7 @@ { "$schema": "https://json-schema.org/schema", + "title": "Vectormap Executor Schema", + "description": "Build vectormap utility bundles and geographic region data modules", "type": "object", "properties": { "sourceDir": { From e81c8441c690740afe72b0f9ed806dfea753ae09 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Tue, 5 May 2026 14:46:57 +0300 Subject: [PATCH 22/27] fix(nx-infra-plugin): handle flat transpile layout in state-manager-optimize and fail fast --- .../executor.e2e.spec.ts | 52 ++++++++++++++- .../state-manager-optimize.impl.ts | 63 ++++++++++--------- 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.e2e.spec.ts index e0df6dad2f29..4246a88e7ea2 100644 --- a/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.e2e.spec.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; +import { logger } from '@nx/devkit'; import executor from './executor'; import { StateManagerOptimizeExecutorSchema } from './schema'; import { @@ -37,9 +38,9 @@ describe('StateManagerOptimizeExecutor E2E', () => { const indexPathFor = (variant: Variant): string => path.join(stateManagerAbsoluteFor(variant), 'index.js'); - const runOptimize = async (): Promise => { + const runOptimize = async (): Promise<{ success: boolean }> => { const options: StateManagerOptimizeExecutorSchema = { transpiledDirs: [TRANSPILED_DIR] }; - await executor(options, context); + return executor(options, context); }; beforeEach(async () => { @@ -98,4 +99,51 @@ describe('StateManagerOptimizeExecutor E2E', () => { const cjsIndexContent = await readFileText(indexPathFor('cjs')); expect(cjsIndexContent).toContain('require("./prod/index")'); }, 30000); + + it('should optimize index.js when state_manager lives at flat root layout (no cjs/esm variant subdir)', async () => { + const flatStateManagerDir = path.join(projectDir, TRANSPILED_DIR, STATE_MANAGER_RELATIVE_PATH); + await writeFileText(path.join(flatStateManagerDir, 'index.js'), INDEX_DEV_CONTENT); + await writeFileText(path.join(flatStateManagerDir, 'dev', 'index.js'), DEV_FILE_CONTENT); + await writeFileText(path.join(flatStateManagerDir, 'prod', 'index.js'), PROD_FILE_CONTENT); + await writeFileText( + path.join(flatStateManagerDir, 'state_manager.test.js'), + TOP_LEVEL_NON_INDEX_CONTENT, + ); + + await runOptimize(); + + expect(await readFileText(path.join(flatStateManagerDir, 'index.js'))).toBe(INDEX_PROD_CONTENT); + expect(fs.existsSync(path.join(flatStateManagerDir, 'dev'))).toBe(false); + expect(fs.existsSync(path.join(flatStateManagerDir, 'state_manager.test.js'))).toBe(false); + expect(await readFileText(path.join(flatStateManagerDir, 'prod', 'index.js'))).toBe( + PROD_FILE_CONTENT, + ); + }, 30000); + + it('should optimize state_manager at any depth (mixed flat + nested in same tree)', async () => { + const flatStateManagerDir = path.join(projectDir, TRANSPILED_DIR, STATE_MANAGER_RELATIVE_PATH); + await writeFileText(path.join(flatStateManagerDir, 'index.js'), INDEX_DEV_CONTENT); + await writeFileText(path.join(flatStateManagerDir, 'dev', 'index.js'), DEV_FILE_CONTENT); + await writeFileText(path.join(flatStateManagerDir, 'prod', 'index.js'), PROD_FILE_CONTENT); + + await runOptimize(); + + expect(await readFileText(path.join(flatStateManagerDir, 'index.js'))).toBe(INDEX_PROD_CONTENT); + expect(await readFileText(indexPathFor('cjs'))).toContain('require("./prod/index")'); + }, 30000); + + it('should throw when no state_manager directories are found in any transpiledDir', async () => { + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined); + + const options: StateManagerOptimizeExecutorSchema = { + transpiledDirs: ['./artifacts/transpiled-no-state-manager'], + }; + const result = await executor(options, context); + + expect(result.success).toBe(false); + const errorMessage = String(errorSpy.mock.calls[0][0]); + expect(errorMessage).toContain('No state_manager/index.js'); + + errorSpy.mockRestore(); + }, 30000); }); diff --git a/packages/nx-infra-plugin/src/executors/state-manager-optimize/state-manager-optimize.impl.ts b/packages/nx-infra-plugin/src/executors/state-manager-optimize/state-manager-optimize.impl.ts index 89b5e3b5d2c7..14e45dcc7aa2 100644 --- a/packages/nx-infra-plugin/src/executors/state-manager-optimize/state-manager-optimize.impl.ts +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/state-manager-optimize.impl.ts @@ -1,21 +1,20 @@ -import * as fs from 'fs-extra'; import * as path from 'path'; import * as babel from '@babel/core'; +import { glob } from 'glob'; import { logger } from '@nx/devkit'; import { createExecutor } from '../../utils/create-executor'; import { writeFileText } from '../../utils/file-operations'; import { removeDirectoryRespectingExclusions } from '../clean/clean.impl'; +import { toPosixPath } from '../../utils/path-resolver'; import { StateManagerOptimizeExecutorSchema } from './schema'; const ESM_REEXPORT = "export * from './prod/index';"; -const STATE_MANAGER_REL_PATH = path.join('__internal', 'core', 'state_manager'); +const STATE_MANAGER_INDEX_GLOB = '**/__internal/core/state_manager/index.js'; const ERROR_BABEL_NO_CODE = 'Babel returned no code for CJS state_manager index.js'; +const ERROR_NO_STATE_MANAGER_FOUND = + 'No state_manager/index.js found in any configured transpiledDirs'; -const VARIANTS = ['esm', 'cjs'] as const; -type Variant = (typeof VARIANTS)[number]; -type ContentBuilder = (indexPath: string) => string; - -function transformReexportToCjs(indexPath: string): string { +export function transformReexportToCjs(indexPath: string): string { const result = babel.transformSync(ESM_REEXPORT, { filename: indexPath, plugins: [['@babel/plugin-transform-modules-commonjs']], @@ -28,33 +27,33 @@ function transformReexportToCjs(indexPath: string): string { return result.code; } -const CONTENT_BUILDERS: Record = { - esm: () => ESM_REEXPORT, - cjs: (indexPath) => transformReexportToCjs(indexPath), -}; - -async function optimizeVariant(variant: Variant, transpiledRoot: string): Promise { - const stateManagerDir = path.join(transpiledRoot, variant, STATE_MANAGER_REL_PATH); - - if (!fs.existsSync(stateManagerDir)) { - logger.verbose(`Skipping ${variant} state_manager: ${stateManagerDir} does not exist`); - return; - } +function isCjsFile(filePath: string): boolean { + return toPosixPath(filePath).includes('/cjs/'); +} - const indexPath = path.join(stateManagerDir, 'index.js'); - if (fs.existsSync(indexPath)) { - await writeFileText(indexPath, CONTENT_BUILDERS[variant](indexPath)); - } +async function optimizeIndexFile(indexPath: string): Promise { + const content = isCjsFile(indexPath) ? transformReexportToCjs(indexPath) : ESM_REEXPORT; + await writeFileText(indexPath, content); + const stateManagerDir = path.dirname(indexPath); await removeDirectoryRespectingExclusions(stateManagerDir, [ indexPath, path.join(stateManagerDir, 'prod'), ]); } -async function optimizeTranspiledDir(transpiledDir: string, projectRoot: string): Promise { - const transpiledRoot = path.join(projectRoot, transpiledDir); - await Promise.all(VARIANTS.map((variant) => optimizeVariant(variant, transpiledRoot))); +async function optimizeTranspiledDir(transpiledRoot: string): Promise { + const indexFiles = await glob(STATE_MANAGER_INDEX_GLOB, { + cwd: toPosixPath(transpiledRoot), + absolute: true, + nodir: true, + }); + + logger.verbose(`Found ${indexFiles.length} state_manager index.js file(s) in ${transpiledRoot}`); + + await Promise.all(indexFiles.map(optimizeIndexFile)); + + return indexFiles.length; } interface ResolvedStateManagerOptimize { @@ -73,10 +72,18 @@ export default createExecutor - optimizeTranspiledDir(transpiledDir, resolved.projectRoot), + optimizeTranspiledDir(path.join(resolved.projectRoot, transpiledDir)), ), ); + + const totalFound = counts.reduce((sum, count) => sum + count, 0); + + if (totalFound === 0) { + throw new Error( + `${ERROR_NO_STATE_MANAGER_FOUND}: ${resolved.transpiledDirs.join(', ')}. Check transpile output layout or transpiledDirs option.`, + ); + } }, }); From 926648a0ee8305e14c9af7fff27d4eab7b17f4d9 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Tue, 5 May 2026 14:51:29 +0300 Subject: [PATCH 23/27] fix(devextreme): align state-manager:optimize inputs/outputs with depth-agnostic layout --- packages/devextreme/project.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 70bddf0f88bd..603bdaf172f8 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -457,12 +457,12 @@ ] }, "inputs": [ - "{projectRoot}/artifacts/transpiled-renovation-npm/{esm,cjs}/__internal/core/state_manager/**/*", - "{projectRoot}/artifacts/transpiled-esm-npm/{esm,cjs}/__internal/core/state_manager/**/*" + "{projectRoot}/artifacts/transpiled-renovation-npm/**/__internal/core/state_manager/**/*", + "{projectRoot}/artifacts/transpiled-esm-npm/**/__internal/core/state_manager/**/*" ], "outputs": [ - "{projectRoot}/artifacts/transpiled-renovation-npm/{esm,cjs}/__internal/core/state_manager", - "{projectRoot}/artifacts/transpiled-esm-npm/{esm,cjs}/__internal/core/state_manager" + "{projectRoot}/artifacts/transpiled-renovation-npm/**/__internal/core/state_manager", + "{projectRoot}/artifacts/transpiled-esm-npm/**/__internal/core/state_manager" ] }, "bundle:build": { From fdade2c8446075e91f05dc6acd805e9dddaeea9e Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Tue, 5 May 2026 15:11:44 +0300 Subject: [PATCH 24/27] fix(nx-infra-plugin): apply license headers AFTER compress beautify to preserve banner indentation --- packages/devextreme/project.json | 14 +++---- .../executors/add-license-headers/schema.ts | 14 +++++++ .../src/executors/compress/compress.impl.ts | 42 ++++++++++++++++++- .../src/executors/compress/schema.json | 18 ++++++++ .../src/executors/compress/schema.ts | 3 ++ .../src/executors/copy-files/schema.ts | 16 ++----- 6 files changed, 85 insertions(+), 22 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 603bdaf172f8..7040cd8a5a03 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -745,12 +745,7 @@ "options": { "files": [ { "from": "./js/aspnet.js", "to": "./artifacts/js/dx.aspnet.mvc.js" } - ], - "applyLicenseHeaders": { - "targetSubdir": "./artifacts/js", - "separator": "", - "includePatterns": ["dx.aspnet.mvc.js"] - } + ] }, "inputs": ["{projectRoot}/js/aspnet.js"], "outputs": ["{projectRoot}/artifacts/js/dx.aspnet.mvc.js"] @@ -759,7 +754,12 @@ "executor": "devextreme-nx-infra-plugin:compress", "options": { "files": ["./artifacts/js/dx.aspnet.mvc.js"], - "mode": { "name": "beautify" } + "mode": { "name": "beautify" }, + "applyLicenseHeaders": { + "targetSubdir": "./artifacts/js", + "separator": "", + "includePatterns": ["dx.aspnet.mvc.js"] + } }, "configurations": { "normalize": { diff --git a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts index 2ca19fed2dac..272ded803383 100644 --- a/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/schema.ts @@ -11,3 +11,17 @@ export interface AddLicenseHeadersExecutorSchema { commentType?: '!' | '*'; mode?: 'eula' | 'mit'; } + +export interface ApplyLicenseHeadersOption { + licenseTemplateFile?: string; + mode?: 'eula' | 'mit'; + eulaUrl?: string; + version?: string; + commentType?: '!' | '*'; + separator?: string; + prependAfterLicense?: string; + filenameMode?: 'relative' | 'basename'; + includePatterns?: readonly string[]; + excludePatterns?: readonly string[]; + targetSubdir?: string; +} diff --git a/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts b/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts index 54eaf9bfffa2..690240c99732 100644 --- a/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts +++ b/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts @@ -6,12 +6,18 @@ import { createExecutor } from '../../utils/create-executor'; import { expandEntries } from '../../utils/glob-discovery'; import { ensureTrailingNewline, + loadProjectPackageJson, normalizeEol, readFileText, writeFileText, } from '../../utils/file-operations'; import { CompressExecutorSchema, CompressMode, CompressModeName } from './schema'; -import { DEFAULT_EULA_URL } from '../add-license-headers/defaults'; +import { applyLicenseHeadersToDirectory } from '../add-license-headers/add-license-headers.impl'; +import { DEFAULT_EULA_URL, resolveLicenseTemplate } from '../add-license-headers/defaults'; +import type { ApplyLicenseHeadersOption } from '../add-license-headers/schema'; + +const ERROR_APPLY_LICENSE_HEADERS_TARGET_SUBDIR_REQUIRED = + 'Compress: applyLicenseHeaders.targetSubdir is required to specify the directory to apply headers to'; const STRIP_DEBUG_REGEX = /\/{2,}\s{0,}#DEBUG[\s\S]*?\/{2,}\s{0,}#ENDDEBUG/g; @@ -132,11 +138,41 @@ async function compressFile(filePath: string, mode: CompressMode): Promise await writeFileText(filePath, await runStrategy(raw, resolved)); } +async function applyLicenseHeadersIfRequested( + applyLicenseHeaders: ApplyLicenseHeadersOption | undefined, + projectRoot: string, +): Promise { + if (!applyLicenseHeaders) { + return; + } + if (!applyLicenseHeaders.targetSubdir) { + throw new Error(ERROR_APPLY_LICENSE_HEADERS_TARGET_SUBDIR_REQUIRED); + } + const pkg = await loadProjectPackageJson(projectRoot); + const templatePath = resolveLicenseTemplate(projectRoot, applyLicenseHeaders); + const targetDir = path.join(projectRoot, applyLicenseHeaders.targetSubdir); + await applyLicenseHeadersToDirectory({ + targetDir, + pkg, + templatePath, + eulaUrl: applyLicenseHeaders.eulaUrl ?? DEFAULT_EULA_URL, + mode: applyLicenseHeaders.mode, + version: applyLicenseHeaders.version, + commentType: applyLicenseHeaders.commentType, + separator: applyLicenseHeaders.separator, + prependAfterLicense: applyLicenseHeaders.prependAfterLicense, + filenameMode: applyLicenseHeaders.filenameMode, + includePatterns: applyLicenseHeaders.includePatterns, + excludePatterns: applyLicenseHeaders.excludePatterns, + }); +} + interface ResolvedCompress { projectRoot: string; files: string[]; mode: CompressMode; modeName: string; + applyLicenseHeaders?: ApplyLicenseHeadersOption; } export default createExecutor({ @@ -151,12 +187,14 @@ export default createExecutor({ files: expanded, mode: options.mode, modeName: typeof options.mode === 'string' ? options.mode : options.mode.name, + applyLicenseHeaders: options.applyLicenseHeaders, }; }, - run: async ({ projectRoot, files, mode, modeName }) => { + run: async ({ projectRoot, files, mode, modeName, applyLicenseHeaders }) => { for (const filePath of files) { await compressFile(filePath, mode); logger.verbose(`Compressed ${path.relative(projectRoot, filePath)} (${modeName})`); } + await applyLicenseHeadersIfRequested(applyLicenseHeaders, projectRoot); }, }); diff --git a/packages/nx-infra-plugin/src/executors/compress/schema.json b/packages/nx-infra-plugin/src/executors/compress/schema.json index 274d2e47b392..48638ece8130 100644 --- a/packages/nx-infra-plugin/src/executors/compress/schema.json +++ b/packages/nx-infra-plugin/src/executors/compress/schema.json @@ -41,6 +41,24 @@ "type": "array", "items": { "type": "string" }, "description": "Glob patterns for files to exclude from compression (relative to project root)" + }, + "applyLicenseHeaders": { + "type": "object", + "description": "When provided, applies DevExtreme license headers after compression. The targetSubdir field is required and resolves relative to the project root.", + "properties": { + "licenseTemplateFile": { "type": "string" }, + "mode": { "type": "string", "enum": ["eula", "mit"] }, + "eulaUrl": { "type": "string" }, + "version": { "type": "string" }, + "commentType": { "type": "string", "enum": ["!", "*"] }, + "separator": { "type": "string" }, + "prependAfterLicense": { "type": "string" }, + "filenameMode": { "type": "string", "enum": ["relative", "basename"] }, + "includePatterns": { "type": "array", "items": { "type": "string" } }, + "excludePatterns": { "type": "array", "items": { "type": "string" } }, + "targetSubdir": { "type": "string" } + }, + "required": ["targetSubdir"] } }, "required": ["files", "mode"] diff --git a/packages/nx-infra-plugin/src/executors/compress/schema.ts b/packages/nx-infra-plugin/src/executors/compress/schema.ts index 8f89483fd5bd..22ba2a2e2528 100644 --- a/packages/nx-infra-plugin/src/executors/compress/schema.ts +++ b/packages/nx-infra-plugin/src/executors/compress/schema.ts @@ -1,3 +1,5 @@ +import type { ApplyLicenseHeadersOption } from '../add-license-headers/schema'; + export type CompressModeName = 'minify' | 'beautify' | 'strip-debug' | 'normalize'; export type CompressMode = @@ -16,4 +18,5 @@ export interface CompressExecutorSchema { files: string[]; mode: CompressMode; exclude?: string[]; + applyLicenseHeaders?: ApplyLicenseHeadersOption; } diff --git a/packages/nx-infra-plugin/src/executors/copy-files/schema.ts b/packages/nx-infra-plugin/src/executors/copy-files/schema.ts index bfd6b85e95a3..7278f547df23 100644 --- a/packages/nx-infra-plugin/src/executors/copy-files/schema.ts +++ b/packages/nx-infra-plugin/src/executors/copy-files/schema.ts @@ -1,16 +1,6 @@ -export interface ApplyLicenseHeadersOption { - licenseTemplateFile?: string; - mode?: 'eula' | 'mit'; - eulaUrl?: string; - version?: string; - commentType?: '!' | '*'; - separator?: string; - prependAfterLicense?: string; - filenameMode?: 'relative' | 'basename'; - includePatterns?: readonly string[]; - excludePatterns?: readonly string[]; - targetSubdir?: string; -} +import type { ApplyLicenseHeadersOption } from '../add-license-headers/schema'; + +export type { ApplyLicenseHeadersOption }; export interface CopyFilesExecutorSchema { files: Array<{ From bf91f5c3ecf234a5e1bc9f1e141431753fac2fda Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Wed, 6 May 2026 14:03:44 +0300 Subject: [PATCH 25/27] fix(nx-infra-plugin): respect BUILD_INTERNAL_PACKAGE env in npm-assemble --- packages/devextreme/project.json | 5 +- .../npm-assemble/npm-assemble.impl.ts | 62 ++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 7040cd8a5a03..9cedc83173ef 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -880,6 +880,7 @@ ] }, "inputs": [ + "internalPackageEnv", "{projectRoot}/artifacts/transpiled-esm-npm/**/*", "{projectRoot}/js/**/*.json", "{projectRoot}/license/**/*", @@ -1128,6 +1129,7 @@ "parallel": false }, "inputs": [ + "internalPackageEnv", "devextremeDistMeta", "{workspaceRoot}/packages/devextreme-dist/package.json", "{workspaceRoot}/packages/devextreme-scss/scss/**/*", @@ -1377,7 +1379,8 @@ "{projectRoot}/webpack.config.js" ], "internalPackageEnv": [ - { "env": "BUILD_TEST_INTERNAL_PACKAGE" } + { "env": "BUILD_TEST_INTERNAL_PACKAGE" }, + { "env": "BUILD_INTERNAL_PACKAGE" } ], "devextremeDistMeta": [ "{workspaceRoot}/packages/devextreme-dist/README.md", diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts index 5af28f14a1f9..d37270695a60 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts @@ -23,16 +23,31 @@ import { NpmAssembleMetadataFile, } from './schema'; -const SRC_JS_EXCLUDES = [ +function isInternalPackageBuild(): boolean { + return ( + process.env.BUILD_INTERNAL_PACKAGE === 'true' + || process.env.BUILD_TEST_INTERNAL_PACKAGE === 'true' + ); +} + +const SRC_JS_BASE_EXCLUDES = [ 'bundles/*.js', 'cjs/bundles/**/*', 'esm/bundles/**/*', 'bundles/modules/parts/*.js', 'viz/vector_map.utils/*.js', 'viz/docs/*.js', - '**/license/license_validation_internal.js', ]; +function buildSrcJsExcludes(internalBuild: boolean): string[] { + return [ + ...SRC_JS_BASE_EXCLUDES, + internalBuild + ? '**/license/license_validation.js' + : '**/license/license_validation_internal.js', + ]; +} + const DIST_EXCLUDES = [ 'transpiled**/**/*', 'npm/**/*.*', @@ -61,17 +76,43 @@ const DIST_EXCLUDES = [ 'js/dx-quill*', ]; -const SRC_JS_HEADER_EXCLUDES = [...SRC_JS_EXCLUDES, 'dist/**/*', 'bin/**/*', 'license/**/*']; +function buildSrcJsHeaderExcludes(internalBuild: boolean): string[] { + return [...buildSrcJsExcludes(internalBuild), 'dist/**/*', 'bin/**/*', 'license/**/*']; +} const VECTOR_MAP_UTILS_EXCLUDES = ['viz/vector_map.utils/**']; -async function copySourceJs(transpiledDir: string, outputDir: string): Promise { +async function copySourceJs( + transpiledDir: string, + outputDir: string, + internalBuild: boolean, +): Promise { await copyDirectory(transpiledDir, outputDir, { include: ['**/*.js'], - exclude: SRC_JS_EXCLUDES, + exclude: buildSrcJsExcludes(internalBuild), }); } +async function renameInternalLicenseValidator(outputDir: string): Promise { + const licenseDirs = await glob('**/__internal/core/license', { + cwd: toPosixPath(outputDir), + absolute: true, + }); + await Promise.all( + licenseDirs.map(async (licenseDir) => { + const internalPath = path.join(licenseDir, 'license_validation_internal.js'); + const defaultPath = path.join(licenseDir, 'license_validation.js'); + try { + await fs.rename(internalPath, defaultPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + }), + ); +} + async function copyEsmPackageJsonFiles(transpiledDir: string, outputDir: string): Promise { await copyDirectory(transpiledDir, outputDir, { include: ['**/*.json'], @@ -142,6 +183,7 @@ interface ResolvedNpmAssemble { outputDir: string; metadataFiles: ResolvedMetadataFile[]; flattenSteps: ResolvedFlattenStep[]; + internalBuild: boolean; } function resolveMetadataFiles( @@ -202,6 +244,7 @@ export default createExecutor({ outputDir, metadataFiles: resolveMetadataFiles(options.metadataFiles, projectRoot, outputDir), flattenSteps: resolveFlattenSteps(options.flatten, projectRoot, outputDir), + internalBuild: isInternalPackageBuild(), }; }, run: async (resolved) => { @@ -212,7 +255,7 @@ export default createExecutor({ ); await Promise.all([ - copySourceJs(resolved.transpiledDir, resolved.outputDir), + copySourceJs(resolved.transpiledDir, resolved.outputDir, resolved.internalBuild), copyEsmPackageJsonFiles(resolved.transpiledDir, resolved.outputDir), copyJsSrcJsonFiles(resolved.jsSrcDir, resolved.outputDir), copyLicenseFiles(resolved.licenseSrcDir, resolved.outputDir), @@ -222,6 +265,11 @@ export default createExecutor({ ]); logger.verbose('Assembled npm package contents'); + if (resolved.internalBuild) { + await renameInternalLicenseValidator(resolved.outputDir); + logger.verbose('Renamed internal license validator to default'); + } + await applyLicenseHeadersToDirectory({ targetDir: resolved.outputDir, pkg: resolved.pkg, @@ -229,7 +277,7 @@ export default createExecutor({ eulaUrl: resolved.eulaUrl, commentType: '*', includePatterns: ['**/*.js'], - excludePatterns: SRC_JS_HEADER_EXCLUDES, + excludePatterns: buildSrcJsHeaderExcludes(resolved.internalBuild), filenameMode: 'relative', }); logger.verbose('Applied star-license banners to source JS files'); From 7350de400a7d2296f888fafb63e705a4473507db Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Wed, 6 May 2026 14:47:11 +0300 Subject: [PATCH 26/27] chore: drive internal-mode npm build via Nx configurations --- packages/devextreme/gulpfile.js | 30 ++- packages/devextreme/project.json | 208 +++++++++++++++++- .../npm-assemble/executor.e2e.spec.ts | 70 ++++++ .../npm-assemble/npm-assemble.impl.ts | 145 ++++++------ .../src/executors/npm-assemble/schema.json | 68 +++--- .../src/executors/npm-assemble/schema.ts | 10 + 6 files changed, 409 insertions(+), 122 deletions(-) diff --git a/packages/devextreme/gulpfile.js b/packages/devextreme/gulpfile.js index 0fbd9ddaad2d..3e3bebe1f7f3 100644 --- a/packages/devextreme/gulpfile.js +++ b/packages/devextreme/gulpfile.js @@ -73,11 +73,31 @@ gulp.task('check-license-notices', shell.task('pnpm nx run devextreme:verify:lic gulp.task('state-manager-optimize', shell.task('pnpm nx run devextreme:state-manager:optimize')); -gulp.task('npm', shell.task( - context.uglify - ? 'pnpm nx run devextreme:build:npm -c production' - : 'pnpm nx run devextreme:build:npm' -)); +function getNpmConfiguration() { + if(context.uglify && env.BUILD_INTERNAL_PACKAGE) { + return 'production-internal'; + } + if(env.BUILD_INTERNAL_PACKAGE) { + return 'internal'; + } + if(context.uglify && env.BUILD_TEST_INTERNAL_PACKAGE) { + return 'production-test-internal'; + } + if(env.BUILD_TEST_INTERNAL_PACKAGE) { + return 'test-internal'; + } + if(context.uglify) { + return 'production'; + } + return ''; +} + +gulp.task('npm', shell.task((function() { + const config = getNpmConfiguration(); + return config + ? `pnpm nx run devextreme:build:npm -c ${config}` + : 'pnpm nx run devextreme:build:npm'; +})())); if(env.TEST_CI) { console.warn('Using test CI mode!'); diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 9cedc83173ef..57272c8b1b82 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -821,13 +821,20 @@ "outputDir": "./artifacts/npm/devextreme", "templatesDir": "./build/npm-templates" }, + "configurations": { + "internal": { + "outputDir": "./artifacts/npm/devextreme-internal" + } + }, "inputs": [ "{projectRoot}/js/**/*.d.ts", "{projectRoot}/build/npm-templates/**/*" ], "outputs": [ "{projectRoot}/artifacts/npm/devextreme/**/*.d.ts", - "{projectRoot}/artifacts/npm/devextreme/bundles/dx.all.js" + "{projectRoot}/artifacts/npm/devextreme/bundles/dx.all.js", + "{projectRoot}/artifacts/npm/devextreme-internal/**/*.d.ts", + "{projectRoot}/artifacts/npm/devextreme-internal/bundles/dx.all.js" ] }, "build:npm:dts-bundle": { @@ -837,13 +844,19 @@ "artifactPath": "./artifacts/ts/dx.all.d.ts", "packagePath": "./artifacts/npm/devextreme/bundles/dx.all.d.ts" }, + "configurations": { + "internal": { + "packagePath": "./artifacts/npm/devextreme-internal/bundles/dx.all.d.ts" + } + }, "inputs": [ "{projectRoot}/ts/dx.all.d.ts", "{projectRoot}/ts/aliases.d.ts" ], "outputs": [ "{projectRoot}/artifacts/ts/dx.all.d.ts", - "{projectRoot}/artifacts/npm/devextreme/bundles/dx.all.d.ts" + "{projectRoot}/artifacts/npm/devextreme/bundles/dx.all.d.ts", + "{projectRoot}/artifacts/npm/devextreme-internal/bundles/dx.all.d.ts" ] }, "build:npm:dist:package-json": { @@ -853,11 +866,19 @@ "distDirectory": "./artifacts/npm/devextreme-dist", "versionFrom": "./package.json" }, + "configurations": { + "internal": { + "distDirectory": "./artifacts/npm/devextreme-dist-internal" + } + }, "inputs": [ "{workspaceRoot}/packages/devextreme-dist/package.json", "{projectRoot}/package.json" ], - "outputs": ["{projectRoot}/artifacts/npm/devextreme-dist/package.json"] + "outputs": [ + "{projectRoot}/artifacts/npm/devextreme-dist/package.json", + "{projectRoot}/artifacts/npm/devextreme-dist-internal/package.json" + ] }, "build:npm:assemble": { "executor": "devextreme-nx-infra-plugin:npm-assemble", @@ -869,6 +890,43 @@ "webpackConfig": "./webpack.config.js", "artifactsDir": "./artifacts", "outputDir": "./artifacts/npm/devextreme", + "srcExcludes": [ + "bundles/*.js", + "cjs/bundles/**/*", + "esm/bundles/**/*", + "bundles/modules/parts/*.js", + "viz/vector_map.utils/*.js", + "viz/docs/*.js" + ], + "distExcludes": [ + "transpiled**/**/*", + "npm/**/*.*", + "ts/jquery*", + "ts/knockout*", + "ts/globalize*", + "ts/cldr*", + "css/dx-diagram.*", + "css/dx-gantt.*", + "js/knockout*", + "js/cldr/*.*", + "js/cldr*", + "js/globalize/*.*", + "js/globalize*", + "js/dx-exceljs-fork*", + "js/file-saver*", + "js/jquery*", + "js/jspdf*", + "js/jspdf-autotable*", + "js/jszip*", + "js/dx.custom*", + "js/dx.viz*", + "js/dx.web*", + "js/dx-diagram*", + "js/dx-gantt*", + "js/dx-quill*" + ], + "nestedPackageJsonExcludes": ["viz/vector_map.utils/**"], + "excludeLicenseValidator": "**/license/license_validation_internal.js", "metadataFiles": [ { "from": "../../README.md", "to": "./README.md" }, { "from": "./build/npm-templates/.npmignore", "to": "./.npmignore" }, @@ -879,6 +937,32 @@ { "from": "./dist", "to": "./artifacts/npm/devextreme-dist" } ] }, + "configurations": { + "internal": { + "outputDir": "./artifacts/npm/devextreme-internal", + "excludeLicenseValidator": "**/license/license_validation.js", + "renameLicenseValidator": { + "fromGlob": "**/license/license_validation_internal.js", + "toBasename": "license_validation.js" + }, + "metadataFiles": [ + { "from": "../../README.md", "to": "./README.md" }, + { "from": "./build/npm-templates/.npmignore", "to": "./.npmignore" }, + { "from": "../devextreme-dist/README.md", "to": "../devextreme-dist-internal/README.md" }, + { "from": "../devextreme-dist/LICENSE.md", "to": "../devextreme-dist-internal/LICENSE.md" } + ], + "flatten": [ + { "from": "./dist", "to": "./artifacts/npm/devextreme-dist-internal" } + ] + }, + "test-internal": { + "excludeLicenseValidator": "**/license/license_validation.js", + "renameLicenseValidator": { + "fromGlob": "**/license/license_validation_internal.js", + "toBasename": "license_validation.js" + } + } + }, "inputs": [ "internalPackageEnv", "{projectRoot}/artifacts/transpiled-esm-npm/**/*", @@ -897,7 +981,9 @@ "{projectRoot}/artifacts/npm/devextreme/**/*", "{projectRoot}/artifacts/npm/devextreme-dist/README.md", "{projectRoot}/artifacts/npm/devextreme-dist/LICENSE.md", - "{projectRoot}/artifacts/npm/devextreme-dist/**/*" + "{projectRoot}/artifacts/npm/devextreme-dist/**/*", + "{projectRoot}/artifacts/npm/devextreme-internal/**/*", + "{projectRoot}/artifacts/npm/devextreme-dist-internal/**/*" ] }, "build:npm:scss": { @@ -906,13 +992,19 @@ "scssPackagePath": "../devextreme-scss", "outputDir": "./artifacts/npm/devextreme/scss" }, + "configurations": { + "internal": { + "outputDir": "./artifacts/npm/devextreme-internal/scss" + } + }, "inputs": [ "{workspaceRoot}/packages/devextreme-scss/scss/**/*", "{workspaceRoot}/packages/devextreme-scss/fonts/**/*", "{workspaceRoot}/packages/devextreme-scss/icons/**/*" ], "outputs": [ - "{projectRoot}/artifacts/npm/devextreme/scss/**/*" + "{projectRoot}/artifacts/npm/devextreme/scss/**/*", + "{projectRoot}/artifacts/npm/devextreme-internal/scss/**/*" ] }, "build:npm:root-package-json": { @@ -923,10 +1015,19 @@ "setName": "devextreme", "removeFields": ["devDependencies", "publishConfig", "scripts"] }, + "configurations": { + "internal": { + "distDirectory": "./artifacts/npm/devextreme-internal", + "setName": "devextreme-internal" + } + }, "inputs": [ "{projectRoot}/package.json" ], - "outputs": ["{projectRoot}/artifacts/npm/devextreme/package.json"] + "outputs": [ + "{projectRoot}/artifacts/npm/devextreme/package.json", + "{projectRoot}/artifacts/npm/devextreme-internal/package.json" + ] }, "compress:npm-sources": { "executor": "devextreme-nx-infra-plugin:compress", @@ -948,13 +1049,44 @@ "configurations": { "production": { "mode": { "name": "beautify" } + }, + "internal": { + "files": ["./artifacts/npm/devextreme-internal/**/*.js"], + "exclude": [ + "./artifacts/npm/devextreme-internal/bundles/*.js", + "./artifacts/npm/devextreme-internal/cjs/bundles/**", + "./artifacts/npm/devextreme-internal/esm/bundles/**", + "./artifacts/npm/devextreme-internal/bundles/modules/parts/*.js", + "./artifacts/npm/devextreme-internal/viz/vector_map.utils/*.js", + "./artifacts/npm/devextreme-internal/viz/docs/*.js", + "./artifacts/npm/devextreme-internal/dist/**", + "./artifacts/npm/devextreme-internal/bin/**", + "./artifacts/npm/devextreme-internal/license/**" + ] + }, + "production-internal": { + "files": ["./artifacts/npm/devextreme-internal/**/*.js"], + "exclude": [ + "./artifacts/npm/devextreme-internal/bundles/*.js", + "./artifacts/npm/devextreme-internal/cjs/bundles/**", + "./artifacts/npm/devextreme-internal/esm/bundles/**", + "./artifacts/npm/devextreme-internal/bundles/modules/parts/*.js", + "./artifacts/npm/devextreme-internal/viz/vector_map.utils/*.js", + "./artifacts/npm/devextreme-internal/viz/docs/*.js", + "./artifacts/npm/devextreme-internal/dist/**", + "./artifacts/npm/devextreme-internal/bin/**", + "./artifacts/npm/devextreme-internal/license/**" + ], + "mode": { "name": "beautify" } } }, "inputs": [ - "{projectRoot}/artifacts/npm/devextreme/**/*.js" + "{projectRoot}/artifacts/npm/devextreme/**/*.js", + "{projectRoot}/artifacts/npm/devextreme-internal/**/*.js" ], "outputs": [ - "{projectRoot}/artifacts/npm/devextreme/**/*.js" + "{projectRoot}/artifacts/npm/devextreme/**/*.js", + "{projectRoot}/artifacts/npm/devextreme-internal/**/*.js" ] }, "verify:licenses": { @@ -1151,6 +1283,8 @@ "outputs": [ "{projectRoot}/artifacts/npm/devextreme", "{projectRoot}/artifacts/npm/devextreme-dist", + "{projectRoot}/artifacts/npm/devextreme-internal", + "{projectRoot}/artifacts/npm/devextreme-dist-internal", "{projectRoot}/artifacts/ts/dx.all.d.ts" ], "configurations": { @@ -1165,6 +1299,54 @@ "pnpm nx run devextreme:verify:public-modules", "pnpm nx run devextreme:build:npm:scss" ] + }, + "internal": { + "commands": [ + "pnpm nx run devextreme:build:npm:dts-modules -c internal", + "pnpm nx run devextreme:build:npm:dts-bundle -c internal", + "pnpm nx run devextreme:build:npm:dist:package-json -c internal", + "pnpm nx run devextreme:build:npm:assemble -c internal", + "pnpm nx run devextreme:build:npm:root-package-json -c internal", + "pnpm nx run devextreme:compress:npm-sources -c internal", + "pnpm nx run devextreme:verify:public-modules", + "pnpm nx run devextreme:build:npm:scss -c internal" + ] + }, + "production-internal": { + "commands": [ + "pnpm nx run devextreme:build:npm:dts-modules -c internal", + "pnpm nx run devextreme:build:npm:dts-bundle -c internal", + "pnpm nx run devextreme:build:npm:dist:package-json -c internal", + "pnpm nx run devextreme:build:npm:assemble -c internal", + "pnpm nx run devextreme:build:npm:root-package-json -c internal", + "pnpm nx run devextreme:compress:npm-sources -c production-internal", + "pnpm nx run devextreme:verify:public-modules", + "pnpm nx run devextreme:build:npm:scss -c internal" + ] + }, + "test-internal": { + "commands": [ + "pnpm nx run devextreme:build:npm:dts-modules", + "pnpm nx run devextreme:build:npm:dts-bundle", + "pnpm nx run devextreme:build:npm:dist:package-json", + "pnpm nx run devextreme:build:npm:assemble -c test-internal", + "pnpm nx run devextreme:build:npm:root-package-json", + "pnpm nx run devextreme:compress:npm-sources", + "pnpm nx run devextreme:verify:public-modules", + "pnpm nx run devextreme:build:npm:scss" + ] + }, + "production-test-internal": { + "commands": [ + "pnpm nx run devextreme:build:npm:dts-modules", + "pnpm nx run devextreme:build:npm:dts-bundle", + "pnpm nx run devextreme:build:npm:dist:package-json", + "pnpm nx run devextreme:build:npm:assemble -c test-internal", + "pnpm nx run devextreme:build:npm:root-package-json", + "pnpm nx run devextreme:compress:npm-sources -c production", + "pnpm nx run devextreme:verify:public-modules", + "pnpm nx run devextreme:build:npm:scss" + ] } } }, @@ -1196,7 +1378,15 @@ "testing": { "env": { "BUILD_TEST_INTERNAL_PACKAGE": "true" - } + }, + "commands": [ + "pnpm nx clean:artifacts devextreme", + "pnpm nx build:localization devextreme", + "pnpm nx build:transpile devextreme", + "pnpm nx run-many --targets=bundle:debug,bundle:prod,build:vectormap,copy:vendor,build:aspnet,build:declarations --projects=devextreme --parallel", + "pnpm nx build:npm devextreme -c test-internal", + "pnpm nx verify:licenses devextreme" + ] }, "production": { "commands": [ diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts index 41e8cb269d30..a34ce67fda41 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts @@ -25,6 +25,9 @@ const OPTIONS: NpmAssembleExecutorSchema = { outputDir: './artifacts/npm/devextreme', licenseTemplateFile: './build/gulp/license-header.txt', eulaUrl: 'https://js.devexpress.com/Licensing/', + srcExcludes: ['bundles/**/*'], + distExcludes: ['js/jquery*'], + excludeLicenseValidator: '**/license/license_validation_internal.js', }; describe('NpmAssembleExecutor E2E', () => { @@ -207,4 +210,71 @@ describe('NpmAssembleExecutor E2E', () => { expect(await readFileText(path.join(distMetaDir, 'README.md'))).toBe('dist readme'); expect(fs.existsSync(path.join(distMetaDir, 'js', 'dx.all.js'))).toBe(true); }); + + it('should exclude default license validator when excludeLicenseValidator targets it', async () => { + const transpiledDir = path.join(projectDir, 'artifacts', 'transpiled-esm-npm'); + fs.mkdirSync(path.join(transpiledDir, 'esm', 'license'), { recursive: true }); + + await writeFileText( + path.join(transpiledDir, 'esm', 'license', 'license_validation.js'), + 'var d = {};', + ); + await writeFileText( + path.join(transpiledDir, 'esm', 'license', 'license_validation_internal.js'), + 'var i = {};', + ); + + const internalOptions: NpmAssembleExecutorSchema = { + ...OPTIONS, + outputDir: './artifacts/npm/devextreme-internal', + excludeLicenseValidator: '**/license/license_validation.js', + renameLicenseValidator: { + fromGlob: '**/license/license_validation_internal.js', + toBasename: 'license_validation.js', + }, + }; + + const result = await executor(internalOptions, context); + expect(result.success).toBe(true); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme-internal'); + expect(fs.existsSync(path.join(outDir, 'esm', 'license', 'license_validation.js'))).toBe(true); + expect( + fs.existsSync(path.join(outDir, 'esm', 'license', 'license_validation_internal.js')), + ).toBe(false); + + const renamedContent = await readFileText( + path.join(outDir, 'esm', 'license', 'license_validation.js'), + ); + expect(renamedContent).toContain('var i = {};'); + }); + + it('should write to a different outputDir/flatten target when overridden via options', async () => { + const transpiledDir = path.join(projectDir, 'artifacts', 'transpiled-esm-npm'); + const artifactsDir = path.join(projectDir, 'artifacts'); + fs.mkdirSync(path.join(transpiledDir, 'esm'), { recursive: true }); + fs.mkdirSync(path.join(artifactsDir, 'js'), { recursive: true }); + + await writeFileText(path.join(transpiledDir, 'esm', 'button.js'), 'export class Button {}'); + await writeFileText(path.join(artifactsDir, 'js', 'dx.all.js'), 'var dx = {};'); + + const internalOptions: NpmAssembleExecutorSchema = { + ...OPTIONS, + outputDir: './artifacts/npm/devextreme-internal', + flatten: [{ from: './dist', to: './artifacts/npm/devextreme-dist-internal' }], + }; + + const result = await executor(internalOptions, context); + expect(result.success).toBe(true); + + const internalDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme-internal'); + const internalDistDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme-dist-internal'); + const regularDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + const regularDistDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme-dist'); + + expect(fs.existsSync(path.join(internalDir, 'esm', 'button.js'))).toBe(true); + expect(fs.existsSync(path.join(internalDistDir, 'js', 'dx.all.js'))).toBe(true); + expect(fs.existsSync(regularDir)).toBe(false); + expect(fs.existsSync(regularDistDir)).toBe(false); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts index d37270695a60..5f2655dd9f89 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts @@ -21,89 +21,42 @@ import { NpmAssembleExecutorSchema, NpmAssembleFlattenStep, NpmAssembleMetadataFile, + NpmAssembleRename, } from './schema'; -function isInternalPackageBuild(): boolean { - return ( - process.env.BUILD_INTERNAL_PACKAGE === 'true' - || process.env.BUILD_TEST_INTERNAL_PACKAGE === 'true' - ); +function buildSrcExcludes( + baseExcludes: readonly string[], + excludeLicenseValidator: string | undefined, +): string[] { + return excludeLicenseValidator ? [...baseExcludes, excludeLicenseValidator] : [...baseExcludes]; } -const SRC_JS_BASE_EXCLUDES = [ - 'bundles/*.js', - 'cjs/bundles/**/*', - 'esm/bundles/**/*', - 'bundles/modules/parts/*.js', - 'viz/vector_map.utils/*.js', - 'viz/docs/*.js', -]; - -function buildSrcJsExcludes(internalBuild: boolean): string[] { - return [ - ...SRC_JS_BASE_EXCLUDES, - internalBuild - ? '**/license/license_validation.js' - : '**/license/license_validation_internal.js', - ]; -} - -const DIST_EXCLUDES = [ - 'transpiled**/**/*', - 'npm/**/*.*', - 'ts/jquery*', - 'ts/knockout*', - 'ts/globalize*', - 'ts/cldr*', - 'css/dx-diagram.*', - 'css/dx-gantt.*', - 'js/knockout*', - 'js/cldr/*.*', - 'js/cldr*', - 'js/globalize/*.*', - 'js/globalize*', - 'js/dx-exceljs-fork*', - 'js/file-saver*', - 'js/jquery*', - 'js/jspdf*', - 'js/jspdf-autotable*', - 'js/jszip*', - 'js/dx.custom*', - 'js/dx.viz*', - 'js/dx.web*', - 'js/dx-diagram*', - 'js/dx-gantt*', - 'js/dx-quill*', -]; - -function buildSrcJsHeaderExcludes(internalBuild: boolean): string[] { - return [...buildSrcJsExcludes(internalBuild), 'dist/**/*', 'bin/**/*', 'license/**/*']; +function buildSrcHeaderExcludes(srcExcludes: readonly string[]): string[] { + return [...srcExcludes, 'dist/**/*', 'bin/**/*', 'license/**/*']; } -const VECTOR_MAP_UTILS_EXCLUDES = ['viz/vector_map.utils/**']; - async function copySourceJs( transpiledDir: string, outputDir: string, - internalBuild: boolean, + excludes: readonly string[], ): Promise { await copyDirectory(transpiledDir, outputDir, { include: ['**/*.js'], - exclude: buildSrcJsExcludes(internalBuild), + exclude: [...excludes], }); } -async function renameInternalLicenseValidator(outputDir: string): Promise { - const licenseDirs = await glob('**/__internal/core/license', { - cwd: toPosixPath(outputDir), - absolute: true, - }); +async function applyRenameLicenseValidator( + outputDir: string, + rename: NpmAssembleRename, +): Promise { + const cwd = toPosixPath(outputDir); + const matches = await glob(rename.fromGlob, { cwd, absolute: true }); await Promise.all( - licenseDirs.map(async (licenseDir) => { - const internalPath = path.join(licenseDir, 'license_validation_internal.js'); - const defaultPath = path.join(licenseDir, 'license_validation.js'); + matches.map(async (sourcePath) => { + const targetPath = path.join(path.dirname(sourcePath), rename.toBasename); try { - await fs.rename(internalPath, defaultPath); + await fs.rename(sourcePath, targetPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; @@ -113,17 +66,25 @@ async function renameInternalLicenseValidator(outputDir: string): Promise ); } -async function copyEsmPackageJsonFiles(transpiledDir: string, outputDir: string): Promise { +async function copyEsmPackageJsonFiles( + transpiledDir: string, + outputDir: string, + nestedPackageJsonExcludes: readonly string[], +): Promise { await copyDirectory(transpiledDir, outputDir, { include: ['**/*.json'], - exclude: VECTOR_MAP_UTILS_EXCLUDES, + exclude: [...nestedPackageJsonExcludes], }); } -async function copyJsSrcJsonFiles(jsSrcDir: string, outputDir: string): Promise { +async function copyJsSrcJsonFiles( + jsSrcDir: string, + outputDir: string, + nestedPackageJsonExcludes: readonly string[], +): Promise { await copyDirectory(jsSrcDir, outputDir, { include: ['**/*.json'], - exclude: VECTOR_MAP_UTILS_EXCLUDES, + exclude: [...nestedPackageJsonExcludes], }); } @@ -153,10 +114,14 @@ async function copyNpmBinFiles(npmBinDir: string, outputDir: string): Promise { +async function copyDistFiles( + artifactsDir: string, + outputDir: string, + excludes: readonly string[], +): Promise { await copyDirectory(artifactsDir, path.join(outputDir, 'dist'), { include: ['**/*'], - exclude: DIST_EXCLUDES, + exclude: [...excludes], }); } @@ -183,7 +148,11 @@ interface ResolvedNpmAssemble { outputDir: string; metadataFiles: ResolvedMetadataFile[]; flattenSteps: ResolvedFlattenStep[]; - internalBuild: boolean; + srcExcludes: string[]; + srcHeaderExcludes: string[]; + distExcludes: readonly string[]; + nestedPackageJsonExcludes: readonly string[]; + renameLicenseValidator?: NpmAssembleRename; } function resolveMetadataFiles( @@ -230,6 +199,10 @@ export default createExecutor({ const pkg = await loadProjectPackageJson(projectRoot); const templatePath = resolveLicenseTemplate(projectRoot, options); const outputDir = path.resolve(projectRoot, options.outputDir); + const srcExcludes = buildSrcExcludes( + options.srcExcludes ?? [], + options.excludeLicenseValidator, + ); return { pkg, @@ -244,7 +217,11 @@ export default createExecutor({ outputDir, metadataFiles: resolveMetadataFiles(options.metadataFiles, projectRoot, outputDir), flattenSteps: resolveFlattenSteps(options.flatten, projectRoot, outputDir), - internalBuild: isInternalPackageBuild(), + srcExcludes, + srcHeaderExcludes: buildSrcHeaderExcludes(srcExcludes), + distExcludes: options.distExcludes ?? [], + nestedPackageJsonExcludes: options.nestedPackageJsonExcludes ?? [], + renameLicenseValidator: options.renameLicenseValidator, }; }, run: async (resolved) => { @@ -255,19 +232,23 @@ export default createExecutor({ ); await Promise.all([ - copySourceJs(resolved.transpiledDir, resolved.outputDir, resolved.internalBuild), - copyEsmPackageJsonFiles(resolved.transpiledDir, resolved.outputDir), - copyJsSrcJsonFiles(resolved.jsSrcDir, resolved.outputDir), + copySourceJs(resolved.transpiledDir, resolved.outputDir, resolved.srcExcludes), + copyEsmPackageJsonFiles( + resolved.transpiledDir, + resolved.outputDir, + resolved.nestedPackageJsonExcludes, + ), + copyJsSrcJsonFiles(resolved.jsSrcDir, resolved.outputDir, resolved.nestedPackageJsonExcludes), copyLicenseFiles(resolved.licenseSrcDir, resolved.outputDir), copyNpmBinFiles(resolved.npmBinDir, resolved.outputDir), copyFile(resolved.webpackConfigSrc, webpackConfigDestination), - copyDistFiles(resolved.artifactsDir, resolved.outputDir), + copyDistFiles(resolved.artifactsDir, resolved.outputDir, resolved.distExcludes), ]); logger.verbose('Assembled npm package contents'); - if (resolved.internalBuild) { - await renameInternalLicenseValidator(resolved.outputDir); - logger.verbose('Renamed internal license validator to default'); + if (resolved.renameLicenseValidator) { + await applyRenameLicenseValidator(resolved.outputDir, resolved.renameLicenseValidator); + logger.verbose('Renamed license validator'); } await applyLicenseHeadersToDirectory({ @@ -277,7 +258,7 @@ export default createExecutor({ eulaUrl: resolved.eulaUrl, commentType: '*', includePatterns: ['**/*.js'], - excludePatterns: buildSrcJsHeaderExcludes(resolved.internalBuild), + excludePatterns: resolved.srcHeaderExcludes, filenameMode: 'relative', }); logger.verbose('Applied star-license banners to source JS files'); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json index 47619acdc379..70fcf0aaca23 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json @@ -6,35 +6,63 @@ "properties": { "transpiledDir": { "type": "string", - "description": "Directory containing transpiled ESM+CJS sources (relative to project root)." + "description": "Transpiled ESM+CJS sources dir (relative to project root)." }, "jsSrcDir": { "type": "string", - "description": "Directory containing js source JSON files (relative to project root)." + "description": "JS source dir for JSON copy (relative to project root)." }, "licenseSrcDir": { "type": "string", - "description": "Directory containing license files to copy (relative to project root)." + "description": "License files dir (relative to project root)." }, "npmBinDir": { "type": "string", - "description": "Directory containing npm bin scripts (relative to project root)." + "description": "Npm bin scripts dir (relative to project root)." }, "webpackConfig": { "type": "string", - "description": "Path to the webpack config file to copy into bin/ (relative to project root)." + "description": "Webpack config to copy into bin/ (relative to project root)." }, "artifactsDir": { "type": "string", - "description": "Artifacts directory containing js, css, ts sub-dirs for dist copy (relative to project root)." + "description": "Artifacts dir for dist copy (relative to project root)." }, "outputDir": { "type": "string", - "description": "Output directory for the assembled npm package (relative to project root)." + "description": "Assembled package output dir (relative to project root)." + }, + "srcExcludes": { + "type": "array", + "items": { "type": "string" }, + "description": "Globs excluded from source-JS copy (relative to transpiledDir)." + }, + "distExcludes": { + "type": "array", + "items": { "type": "string" }, + "description": "Globs excluded from dist copy (relative to artifactsDir)." + }, + "nestedPackageJsonExcludes": { + "type": "array", + "items": { "type": "string" }, + "description": "Globs excluded from JSON copy to skip nested sub-packages whose own package.json must not merge into the parent tarball." + }, + "excludeLicenseValidator": { + "type": "string", + "description": "Glob of a license validator file to exclude during source-JS copy (e.g. exclude default validator in internal mode, or exclude internal validator in default mode)." + }, + "renameLicenseValidator": { + "type": "object", + "description": "Post-assembly rename: every file matching fromGlob is renamed to toBasename in its directory. Used in internal mode to promote 'license_validation_internal.js' to 'license_validation.js'.", + "properties": { + "fromGlob": { "type": "string", "description": "Glob matching files to rename (relative to outputDir)." }, + "toBasename": { "type": "string", "description": "New basename for matched files." } + }, + "required": ["fromGlob", "toBasename"] }, "licenseTemplateFile": { "type": "string", - "description": "Path to the license header template file (relative to project root)." + "description": "License header template (relative to project root)." }, "eulaUrl": { "type": "string", @@ -42,36 +70,24 @@ }, "metadataFiles": { "type": "array", - "description": "Metadata files (README, LICENSE, .npmignore, etc.) to copy after assembly. 'from' is resolved against projectRoot; 'to' is resolved against outputDir.", + "description": "Files copied after assembly. 'from' resolved against projectRoot, 'to' against outputDir.", "items": { "type": "object", "properties": { - "from": { - "type": "string", - "description": "Source file path, resolved against projectRoot." - }, - "to": { - "type": "string", - "description": "Destination file path, resolved against outputDir." - } + "from": { "type": "string", "description": "Source path (vs projectRoot)." }, + "to": { "type": "string", "description": "Destination path (vs outputDir)." } }, "required": ["from", "to"] } }, "flatten": { "type": "array", - "description": "Secondary directories populated by recursively copying a sub-tree from outputDir. 'from' is resolved against outputDir; 'to' is resolved against projectRoot.", + "description": "Secondary dirs populated by copying sub-trees from outputDir. 'from' vs outputDir, 'to' vs projectRoot.", "items": { "type": "object", "properties": { - "from": { - "type": "string", - "description": "Source directory inside outputDir, resolved as outputDir/from." - }, - "to": { - "type": "string", - "description": "Destination directory, resolved against projectRoot." - } + "from": { "type": "string", "description": "Source dir inside outputDir." }, + "to": { "type": "string", "description": "Destination dir (vs projectRoot)." } }, "required": ["from", "to"] } diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts index c428da3ffc1d..ca0f74261f54 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts @@ -8,6 +8,11 @@ export interface NpmAssembleFlattenStep { to: string; } +export interface NpmAssembleRename { + fromGlob: string; + toBasename: string; +} + export interface NpmAssembleExecutorSchema { transpiledDir: string; jsSrcDir: string; @@ -16,6 +21,11 @@ export interface NpmAssembleExecutorSchema { webpackConfig: string; artifactsDir: string; outputDir: string; + srcExcludes?: string[]; + distExcludes?: string[]; + nestedPackageJsonExcludes?: string[]; + excludeLicenseValidator?: string; + renameLicenseValidator?: NpmAssembleRename; licenseTemplateFile?: string; eulaUrl?: string; metadataFiles?: NpmAssembleMetadataFile[]; From d2b34c85acc25a745e96209472bfb2f96c2a8669 Mon Sep 17 00:00:00 2001 From: Adel Khamatov Date: Wed, 6 May 2026 17:46:37 +0300 Subject: [PATCH 27/27] fix(nx-infra-plugin): force LF in license banners and bin/license copies for cross-platform parity --- .../npm-assemble/executor.e2e.spec.ts | 23 +++++++++++++++++++ .../npm-assemble/npm-assemble.impl.ts | 6 ++--- .../src/utils/license-banner.e2e.spec.ts | 20 ++++++++++++++++ .../src/utils/license-banner.ts | 2 +- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts index a34ce67fda41..fa095efb578b 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts @@ -98,6 +98,29 @@ describe('NpmAssembleExecutor E2E', () => { expect(fs.existsSync(path.join(outDir, 'bin', 'install.js'))).toBe(true); }); + it('should normalize CRLF to LF in copied license/ and bin/ files (gulp-eol parity)', async () => { + await writeFileText( + path.join(projectDir, 'license', 'LICENSE.txt'), + 'DevExtreme License\r\nLine 2\r\nLine 3\r\n', + ); + await writeFileText( + path.join(projectDir, 'build', 'npm-bin', 'install.js'), + 'var a = 1;\r\nvar b = 2;\r\n', + ); + + const result = await executor(OPTIONS, context); + expect(result.success).toBe(true); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + const licenseContent = await readFileText(path.join(outDir, 'license', 'LICENSE.txt')); + const binContent = await readFileText(path.join(outDir, 'bin', 'install.js')); + + expect(licenseContent).not.toContain('\r'); + expect(binContent).not.toContain('\r'); + expect(licenseContent.endsWith('\n')).toBe(true); + expect(binContent.endsWith('\n')).toBe(true); + }); + it('should copy dist files into outputDir/dist with the gulp-equivalent excludes', async () => { const artifactsDir = path.join(projectDir, 'artifacts'); fs.mkdirSync(path.join(artifactsDir, 'js'), { recursive: true }); diff --git a/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts index 5f2655dd9f89..c70539e304cc 100644 --- a/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts @@ -7,9 +7,7 @@ import { toPosixPath } from '../../utils/path-resolver'; import { copyFile, ensureDir, - ensureTrailingNewline, loadProjectPackageJson, - normalizeEol, readFileText, writeFileText, } from '../../utils/file-operations'; @@ -101,7 +99,9 @@ async function copyAndNormalizeFiles( await ensureDir(path.dirname(destination)); await fs.copyFile(path.join(sourceDir, relative), destination); const content = await readFileText(destination); - await writeFileText(destination, ensureTrailingNewline(normalizeEol(content))); + const lfContent = content.replace(/\r\n/g, '\n'); + const withTrailingLf = lfContent.endsWith('\n') ? lfContent : `${lfContent}\n`; + await writeFileText(destination, withTrailingLf); }), ); } diff --git a/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts b/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts index f65605325d1b..d8afdb77401b 100644 --- a/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts @@ -84,4 +84,24 @@ describe('buildLicenseBannerRenderer', () => { it('should throw when repository.url is missing on object form', () => { expect(() => extractGitHubUrl({}, '/fake/path.json')).toThrow("Invalid 'repository' format"); }); + + it('normalizes CRLF in template to LF so banner output is identical across Windows and Linux runners', async () => { + const tempDir = createTempDir('nx-license-renderer-crlf-e2e-'); + try { + const templatePath = path.join(tempDir, 'license.txt'); + await writeFileText( + templatePath, + `/*<%= commentType %>\r\n* <%= file.relative %>\r\n* Version: <%= version %>\r\n*/\r\n`, + ); + const pkg = { name: 'test-pkg', version: '1.0.0' }; + const render = await buildLicenseBannerRenderer({ templatePath, pkg, commentType: '*' }); + + const banner = render('foo.d.ts'); + + expect(banner).not.toContain('\r'); + expect(banner.split('\n').length).toBeGreaterThan(1); + } finally { + cleanupTempDir(tempDir); + } + }); }); diff --git a/packages/nx-infra-plugin/src/utils/license-banner.ts b/packages/nx-infra-plugin/src/utils/license-banner.ts index 9d80d75bc0ff..408e8d5711d5 100644 --- a/packages/nx-infra-plugin/src/utils/license-banner.ts +++ b/packages/nx-infra-plugin/src/utils/license-banner.ts @@ -39,7 +39,7 @@ export async function buildLicenseBannerRenderer( const resolvedVersion = opts.version ?? pkg.version; const now = new Date(); - const templateText = await readFileText(templatePath); + const templateText = (await readFileText(templatePath)).replace(/\r\n/g, '\n'); const compiled = _.template(templateText); return (fileRelative: string) => compiled({