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 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/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/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/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/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/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/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/gulpfile.js b/packages/devextreme/gulpfile.js index 984131a46c4b..3e3bebe1f7f3 100644 --- a/packages/devextreme/gulpfile.js +++ b/packages/devextreme/gulpfile.js @@ -31,12 +31,9 @@ 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'); require('./build/gulp/systemjs'); -require('./build/gulp/state_manager'); function getTranspileConfig() { if(env.TEST_CI) { @@ -72,6 +69,36 @@ 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')); + +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!'); } @@ -102,11 +129,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 fe3ac535430c..57272c8b1b82 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,41 +49,19 @@ "{projectRoot}/js/__internal/core/localization/cldr-data" ] }, - "build:localization:headers": { - "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": [ - "**/*.js" - ] - }, - "inputs": [ - "{projectRoot}/artifacts/js/localization/**/*.js", - "{projectRoot}/build/gulp/license-header.txt" - ], - "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 }, "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", @@ -86,7 +69,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": [ @@ -118,44 +101,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", @@ -204,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": { @@ -213,89 +201,14 @@ } }, "inputs": [ - "{projectRoot}/js/**/*.{js,jsx}", - "!{projectRoot}/js/**/*.d.ts", - "!{projectRoot}/js/__internal/**/*" + "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": [ - "{projectRoot}/js/**/*.json", - "!{projectRoot}/js/__internal/**/*" - ], - "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": [ - "{projectRoot}/js/**/*.json", - "!{projectRoot}/js/__internal/**/*" - ], - "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": [ - "{projectRoot}/js/**/*.json", - "!{projectRoot}/js/__internal/**/*" - ], - "outputs": [ - "{projectRoot}/artifacts/transpiled-esm-npm/**/*.json" - ] - }, "build:npm:esm": { "executor": "devextreme-nx-infra-plugin:babel-transform", "options": { @@ -307,12 +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": [ - "{projectRoot}/js/**/*.{js,jsx}", - "!{projectRoot}/js/**/*.d.ts", - "!{projectRoot}/js/__internal/**/*" + "jsSourcesProduction", + "jsAssetsProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/esm" @@ -329,12 +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": [ - "{projectRoot}/js/**/*.{js,jsx}", - "!{projectRoot}/js/**/*.d.ts", - "!{projectRoot}/js/__internal/**/*" + "jsSourcesProduction", + "jsAssetsProduction" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/cjs" @@ -357,7 +276,7 @@ "removeDebug": true } }, - "inputs": ["{projectRoot}/artifacts/dist_ts/__internal/**/*.{js,jsx}"], + "inputs": ["internalTsArtifacts"], "outputs": [ "{projectRoot}/artifacts/transpiled/__internal", "{projectRoot}/artifacts/transpiled-renovation-npm/__internal" @@ -376,7 +295,7 @@ } }, "inputs": [ - "{projectRoot}/artifacts/dist_ts/__internal/**/*.{js,jsx}" + "internalTsArtifacts" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/esm/__internal" @@ -395,7 +314,7 @@ } }, "inputs": [ - "{projectRoot}/artifacts/dist_ts/__internal/**/*.{js,jsx}" + "internalTsArtifacts" ], "outputs": [ "{projectRoot}/artifacts/transpiled-esm-npm/cjs/__internal" @@ -463,15 +382,12 @@ "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", "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" @@ -482,6 +398,7 @@ "inputs": [ "{projectRoot}/js/**/*.{js,ts,tsx}", "!{projectRoot}/js/**/*.d.ts", + "{projectRoot}/js/**/*.json", "{projectRoot}/build/bundle-templates/**/*.js" ], "outputs": [ @@ -494,13 +411,11 @@ "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", "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" ], @@ -513,15 +428,12 @@ }, "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", "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" @@ -536,7 +448,24 @@ } } }, - "bundle:debug:build": { + "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/**/__internal/core/state_manager/**/*", + "{projectRoot}/artifacts/transpiled-esm-npm/**/__internal/core/state_manager/**/*" + ], + "outputs": [ + "{projectRoot}/artifacts/transpiled-renovation-npm/**/__internal/core/state_manager", + "{projectRoot}/artifacts/transpiled-esm-npm/**/__internal/core/state_manager" + ] + }, + "bundle:build": { "executor": "devextreme-nx-infra-plugin:bundle", "options": { "entries": [ @@ -552,171 +481,120 @@ "webpackConfigPath": "./webpack.config.js" }, "configurations": { - "production": { - "sourceMap": false + "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", + "applyLicenseHeaders": { + "prependAfterLicense": "\"use strict\";\n\n", + "separator": "", + "includePatterns": ["dx.*.debug.js"] + } + }, + "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, + "applyLicenseHeaders": { + "prependAfterLicense": "\"use strict\";\n\n", + "separator": "", + "includePatterns": ["dx.*.debug.js"] + } + }, + "prod": { + "entries": [ + "bundles/dx.ai-integration.js", + "bundles/dx.all.js", + "bundles/dx.web.js", + "bundles/dx.viz.js" + ], + "mode": "production", + "applyLicenseHeaders": { + "prependAfterLicense": "\"use strict\";\n\n", + "separator": "", + "includePatterns": ["dx.all.js", "dx.web.js", "dx.viz.js", "dx.ai-integration.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.debug.js", "{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" - ] - }, - "bundle:headers": { - "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": [ - "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", - "{projectRoot}/build/gulp/license-header.txt" - ], - "outputs": [ - "{projectRoot}/artifacts/js/dx.*.js" + "{projectRoot}/artifacts/js/dx.custom.debug.js", + "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" ] }, "bundle:debug": { "executor": "nx:run-commands", "options": { "commands": [ - "pnpm nx bundle:debug:build devextreme", - "pnpm nx bundle:headers devextreme", - "pnpm nx compress:bundles:debug devextreme" + "pnpm nx bundle:build devextreme -c debug", + "pnpm nx compress:bundles devextreme -c debug" ], "parallel": false }, "configurations": { "production": { "commands": [ - "pnpm nx bundle:debug:build devextreme -c production", - "pnpm nx bundle:headers devextreme", - "pnpm nx compress:bundles:debug devextreme -c production" + "pnpm nx bundle:build devextreme -c debug-production", + "pnpm nx compress:bundles devextreme -c debug-production" ] } }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", "{projectRoot}/artifacts/transpiled-renovation-npm/**/*", - "{projectRoot}/webpack.config.js", - "{projectRoot}/build/gulp/license-header.txt" + "webpackConfig" ], "outputs": [ "{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": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, - "{projectRoot}/artifacts/transpiled-renovation-npm/bundles/**/*", - "{projectRoot}/artifacts/transpiled-renovation-npm/**/*.js", - "{projectRoot}/webpack.config.js" - ], - "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:headers devextreme -c prod", - "pnpm nx compress:bundles:prod devextreme" + "pnpm nx bundle:build devextreme -c prod", + "pnpm nx compress:bundles devextreme -c prod" ], "parallel": false }, "configurations": { "production": { "commands": [ - "pnpm nx bundle:prod:build devextreme", - "pnpm nx bundle:headers devextreme -c prod", - "pnpm nx compress:bundles:prod devextreme -c production" + "pnpm nx bundle:build devextreme -c prod", + "pnpm nx compress:bundles devextreme -c prod-production" ] } }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", "{projectRoot}/artifacts/transpiled-renovation-npm/**/*", - "{projectRoot}/webpack.config.js", - "{projectRoot}/build/gulp/license-header.txt" - ], - "outputs": [ - "{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" + "webpackConfig" ], "outputs": [ "{projectRoot}/artifacts/js/dx.{all,web,viz,ai-integration}.js" ] }, - "compress:bundles:debug": { + "compress:bundles": { "executor": "devextreme-nx-infra-plugin:compress", "options": { "files": [ @@ -729,18 +607,52 @@ "mode": { "name": "normalize", "trailingNewline": false } }, "configurations": { - "production": { - "mode": { - "name": "beautify", - "eulaUrl": "https://js.devexpress.com/Licensing/" - } + "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" } + }, + "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" } } }, "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": { @@ -753,7 +665,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/**/*", @@ -766,48 +682,33 @@ "{projectRoot}/artifacts/js/vectormap-data" ] }, - "build:vectormap:headers": { - "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" - }, - "inputs": [ - "{projectRoot}/artifacts/js/vectormap-utils/**/*", - "{projectRoot}/build/gulp/license-header.txt" - ], - "outputs": [ - "{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" } + }, + "minify": { + "files": [ + "./artifacts/js/vectormap-utils/dx.vectormaputils.js" + ], + "mode": { "name": "minify" } + } } }, "build:vectormap": { @@ -815,8 +716,7 @@ "options": { "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 }, @@ -824,8 +724,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", @@ -835,9 +734,8 @@ "production": { "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" ] } } @@ -852,42 +750,29 @@ "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/" } - } - }, - "build:aspnet:headers": { - "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": "" + "mode": { "name": "beautify" }, + "applyLicenseHeaders": { + "targetSubdir": "./artifacts/js", + "separator": "", + "includePatterns": ["dx.aspnet.mvc.js"] + } }, - "inputs": [ - "{projectRoot}/artifacts/js/dx.aspnet.mvc.js", - "{projectRoot}/build/gulp/license-header.txt" - ], - "outputs": ["{projectRoot}/artifacts/js/dx.aspnet.mvc.js"] + "configurations": { + "normalize": { + "mode": "normalize" + } + } }, "build:aspnet": { "executor": "nx:run-commands", "options": { "commands": [ "pnpm nx build:aspnet:copy devextreme", - "pnpm nx compress:aspnet:normalize devextreme", - "pnpm nx build:aspnet:headers devextreme" + "pnpm nx compress:aspnet devextreme -c normalize" ], "parallel": false }, @@ -895,8 +780,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" ] } }, @@ -930,6 +814,221 @@ "{projectRoot}/artifacts/npm/devextreme/**/*.d.ts" ] }, + "build:npm:dts-modules": { + "executor": "devextreme-nx-infra-plugin:dts-modules", + "options": { + "sourceDir": "./js", + "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-internal/**/*.d.ts", + "{projectRoot}/artifacts/npm/devextreme-internal/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" + }, + "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-internal/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" + }, + "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", + "{projectRoot}/artifacts/npm/devextreme-dist-internal/package.json" + ] + }, + "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", + "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" }, + { "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" } + ] + }, + "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/**/*", + "{projectRoot}/js/**/*.json", + "{projectRoot}/license/**/*", + "{projectRoot}/build/npm-bin/**/*", + "{projectRoot}/build/npm-templates/.npmignore", + "webpackConfig", + "{projectRoot}/artifacts/js/**/*", + "{projectRoot}/artifacts/css/**/*", + "{projectRoot}/artifacts/ts/**/*", + "{workspaceRoot}/README.md", + "devextremeDistMeta" + ], + "outputs": [ + "{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-internal/**/*", + "{projectRoot}/artifacts/npm/devextreme-dist-internal/**/*" + ] + }, + "build:npm:scss": { + "executor": "devextreme-nx-infra-plugin:scss-assemble", + "options": { + "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-internal/scss/**/*" + ] + }, + "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"] + }, + "configurations": { + "internal": { + "distDirectory": "./artifacts/npm/devextreme-internal", + "setName": "devextreme-internal" + } + }, + "inputs": [ + "{projectRoot}/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", "options": { @@ -949,21 +1048,65 @@ }, "configurations": { "production": { - "mode": { "name": "beautify", "eulaUrl": "https://js.devexpress.com/Licensing/" } + "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": { - "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": { @@ -1091,24 +1234,121 @@ "cwd": "{projectRoot}/artifacts/npm/devextreme-dist" } }, - "build:npm": { + "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:assemble", + "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" + ], + "parallel": false + }, + "inputs": [ + "internalPackageEnv", + "devextremeDistMeta", "{workspaceRoot}/packages/devextreme-dist/package.json", - "{projectRoot}/artifacts/transpiled/**/*", - "{projectRoot}/artifacts/transpiled-esm-npm/**/*" + "{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/modules_metadata.json", + "{projectRoot}/ts/dx.all.d.ts", + "{projectRoot}/ts/aliases.d.ts", + "webpackConfig", + "{projectRoot}/package.json" ], "outputs": [ "{projectRoot}/artifacts/npm/devextreme", - "{projectRoot}/artifacts/npm/devextreme-dist" - ] + "{projectRoot}/artifacts/npm/devextreme-dist", + "{projectRoot}/artifacts/npm/devextreme-internal", + "{projectRoot}/artifacts/npm/devextreme-dist-internal", + "{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:assemble", + "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" + ] + }, + "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" + ] + } + } }, "build": { "executor": "nx:run-commands", @@ -1124,9 +1364,7 @@ "parallel": false }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", "default", "test" ], @@ -1140,7 +1378,26 @@ "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": [ + "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" + ] } } }, @@ -1150,9 +1407,7 @@ "script": "build-dist" }, "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", "default", "test" ], @@ -1170,9 +1425,7 @@ "^build" ], "inputs": [ - { - "env": "BUILD_TEST_INTERNAL_PACKAGE" - }, + "internalPackageEnv", { "env": "DEVEXTREME_TEST_CI" }, @@ -1299,6 +1552,29 @@ "{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" }, + { "env": "BUILD_INTERNAL_PACKAGE" } + ], + "devextremeDistMeta": [ + "{workspaceRoot}/packages/devextreme-dist/README.md", + "{workspaceRoot}/packages/devextreme-dist/LICENSE.md" ] }, "tags": [] 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 diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index e09768781710..8672e3559e1c 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -89,6 +89,36 @@ "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" + }, + "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" + }, + "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" + }, + "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", + "description": "Optimize state_manager modules for production builds" } } } 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/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 new file mode 100644 index 000000000000..889103ced5be --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/add-license-headers/defaults.ts @@ -0,0 +1,27 @@ +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; + +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.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/add-license-headers/executor.e2e.spec.ts index 1800cae38b34..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 @@ -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 }); @@ -16,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'), { @@ -47,79 +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('test-package'); - 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:/); + 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('test-package'); - }); + 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 %> @@ -128,170 +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'"); - }); - }); - - 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 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 result1 = await executor(options, context); - expect(result1.success).toBe(true); + const result = await executor(options, context); + expect(result.success).toBe(true); - const contentAfterFirst = await readFileText(path.join(npmDir, 'index.js')); - const headerCount1 = (contentAfterFirst.match(/\/\*!/g) || []).length; + const npmDir = path.join(projectDir, 'npm'); + const content = await readFileText(path.join(npmDir, 'index.js')); - const result2 = await executor(options, context); - expect(result2.success).toBe(true); + 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 contentAfterSecond = await readFileText(path.join(npmDir, 'index.js')); - const headerCount2 = (contentAfterSecond.match(/\/\*!/g) || []).length; + it('should fail gracefully with missing package.json', async () => { + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './nonexistent-package.json', + }; - expect(headerCount1).toBe(1); - expect(headerCount2).toBe(1); - expect(contentAfterFirst).toBe(contentAfterSecond); - }); + const result = await executor(options, context); - it('should skip files that already have license headers', async () => { - const npmDir = path.join(tempDir, 'packages', 'test-lib', 'npm'); + expect(result.success).toBe(false); + }); - await writeFileText( - path.join(npmDir, 'with-header.js'), - `/*!\n * Existing header\n */\nexport const foo = 'bar';\n`, - ); + it('should fail gracefully with invalid package.json', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './package.json', - }; + await writeFileText(path.join(projectDir, 'package.json'), 'not valid json {{{}'); - const result = await executor(options, context); - expect(result.success).toBe(true); + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + }; - const content = await readFileText(path.join(npmDir, 'with-header.js')); + const result = await executor(options, context); - expect(content).toMatch(/^\/\*!/); - expect(content).toContain('Existing header'); - expect(content).not.toContain('test-package'); - }); + expect(result.success).toBe(false); }); - describe('Error handling', () => { - it('should fail gracefully with missing package.json', async () => { - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './nonexistent-package.json', - }; - - const result = await executor(options, context); - - 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 fail gracefully with invalid package.json', async () => { - const projectDir = path.join(tempDir, 'packages', 'test-lib'); + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './empty', + packageJsonPath: './package.json', + }; - await writeFileText(path.join(projectDir, 'package.json'), 'not valid json {{{}'); + const result = await executor(options, context); - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './npm', - packageJsonPath: './package.json', - }; + expect(result.success).toBe(true); + }); - const result = await executor(options, context); + 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 }); - expect(result.success).toBe(false); - }); + await writeFileText(path.join(customDir, 'custom.js'), `export const custom = true;\n`); - 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: './dist', + 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); - }); + const content = await readFileText(path.join(customDir, 'custom.js')); + expect(content).toMatch(/^\/\*!/); + expect(content).toContain('custom.js'); }); - 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 }); - - await writeFileText(path.join(customDir, 'custom.js'), `export const custom = true;\n`); - - const options: AddLicenseHeadersExecutorSchema = { - targetDirectory: './dist', - packageJsonPath: './package.json', - }; - - const result = await executor(options, context); - - expect(result.success).toBe(true); + it('should work with custom package.json path', async () => { + const projectDir = path.join(tempDir, 'packages', 'test-lib'); - const content = await readFileText(path.join(customDir, 'custom.js')); - expect(content).toMatch(/^\/\*!/); - expect(content).toContain('test-package'); + await writeJson(path.join(projectDir, 'custom-package.json'), { + name: 'custom-package-name', + version: '2.0.0', + repository: 'https://github.com/DevExpress/custom-package', }); - 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', - }); - - 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('custom-package-name'); - expect(content).toContain('Version: 2.0.0'); - }); + expect(content).toContain('Version: 2.0.0'); }); it('should preserve formatting and whitespace', async () => { @@ -322,4 +284,188 @@ 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 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 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'); + + const options: AddLicenseHeadersExecutorSchema = { + targetDirectory: './npm', + packageJsonPath: './package.json', + commentType: '*', + 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(/^\/\*!/); + 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 342bc40a22d5..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,288 +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'; - -interface PackageJson { - name: string; - version: string; - 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', - INCLUDE_PATTERNS: ['**/*.{ts,js}'], - EXCLUDE_PATTERNS: ['**/*.json', '**/*.map'], -} as const; - -const COMMENT = { - MARKER: '/*!', - 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(): string { - return [ - COMMENT.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[]; - excludePatterns: readonly string[]; -} - -async function discoverFiles(options: DiscoverFilesOptions): Promise { - const { targetDirectory, includePatterns, excludePatterns } = options; - - const patterns = includePatterns.map((pattern) => { - const fullPath = path.join(targetDirectory, pattern); - return isWindowsOS() ? normalizeGlobPathForWindows(fullPath) : fullPath; - }); - - const allFiles: string[] = []; - for (const pattern of patterns) { - const matchedFiles = await glob(pattern, { ignore: [...excludePatterns] }); - allFiles.push(...matchedFiles); - } - - return [...new Set(allFiles)]; -} - -interface ProcessFileOptions { - file: string; - targetDirectory: string; - baseData: BaseTemplateData; - bannerTemplate: string; - compiledTemplate: ReturnType | null; - useCustomTemplate: boolean; - separatorBetweenBannerAndContent: string; - prependAfterLicense: string; -} - -async function processFile(options: ProcessFileOptions): Promise { - const { - file, - targetDirectory, - baseData, - bannerTemplate, - compiledTemplate, - useCustomTemplate, - separatorBetweenBannerAndContent, - prependAfterLicense, - } = options; - - const content = await readFileText(file); - - if (content.startsWith(COMMENT.MARKER)) { - 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, -): Promise { - if (!licenseTemplateFile) { - return { success: true, template: buildDefaultBannerTemplate() }; - } - - 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( - absoluteProjectRoot, - options.targetDirectory ?? DEFAULTS.TARGET_DIR, - ); - const packageJsonPath = path.join( - absoluteProjectRoot, - options.packageJsonPath ?? DEFAULTS.PACKAGE_JSON, - ); - const separatorBetweenBannerAndContent = - options.separatorBetweenBannerAndContent ?? CHARS.NEWLINE; - const prependAfterLicense = options.prependAfterLicense ?? ''; - const useCustomTemplate = !!options.licenseTemplateFile; - - let pkg: PackageJson; - try { - 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); - 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, - includePatterns: options.includePatterns ?? DEFAULTS.INCLUDE_PATTERNS, - excludePatterns: options.excludePatterns ?? DEFAULTS.EXCLUDE_PATTERNS, - }); - - logger.verbose(`Adding license headers to ${files.length} files...`); - - const compiledTemplate = useCustomTemplate ? _.template(bannerTemplate) : null; - - await Promise.all( - files.map((file) => - processFile({ - file, - targetDirectory, - baseData, - bannerTemplate, - compiledTemplate, - useCustomTemplate, - separatorBetweenBannerAndContent, - 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/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 3513654e0605..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": { @@ -50,7 +50,17 @@ "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": "!" + }, + "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 706bc02cfb85..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 @@ -8,4 +8,20 @@ export interface AddLicenseHeadersExecutorSchema { eulaUrl?: string; prependAfterLicense?: string; version?: string; + 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/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..588c25184816 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/babel-transform/babel-transform.impl.ts @@ -0,0 +1,204 @@ +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 { 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, + 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 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; + removeDebug: boolean; + 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({ + 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); + }); + + const resolvedAssets = resolveAssets(options.copyAssets ?? [], projectRoot, options.outDir); + + return { + projectRoot, + babelConfig, + removeDebug, + renameExtensions, + globPattern, + excludePatterns, + resolvedAssets, + }; + }, + 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}`); + + if (resolved.resolvedAssets.length > 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 439f71b4f1c8..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 @@ -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,106 @@ 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); + + 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/executor.ts b/packages/nx-infra-plugin/src/executors/babel-transform/executor.ts index 8809328cdb68..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,142 +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, normalizeGlobPathForWindows } from '../../utils/path-resolver'; -import { isWindowsOS } from '../../utils/common'; - -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 = isWindowsOS() ? normalizeGlobPathForWindows(sourcePath) : sourcePath; - - const rawExcludePatterns = options.excludePatterns ?? []; - const excludePatterns = rawExcludePatterns.map((pattern) => { - const resolved = path.isAbsolute(pattern) ? pattern : path.join(projectRoot, pattern); - return isWindowsOS() ? normalizeGlobPathForWindows(resolved) : resolved; - }); - - const sourceFiles = await glob(globPattern, { - absolute: true, - ignore: 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/babel-transform/schema.json b/packages/nx-infra-plugin/src/executors/babel-transform/schema.json index 0cd7adbb88ec..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", @@ -37,8 +38,33 @@ "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"], - "additionalProperties": false + "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/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-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/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 21a45e0bbc50..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,292 +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 } 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 = isWindowsOS() - ? normalizeGlobPathForWindows(path.join(projectRoot, srcPattern)) - : path.join(projectRoot, srcPattern); - - const resolvedExcludes = excludePatterns.map((pattern) => { - const result = path.join(projectRoot, pattern); - return isWindowsOS() ? normalizeGlobPathForWindows(result) : result; - }); - - const files = await glob(globPattern, { - absolute: true, - nodir: true, - ignore: resolvedExcludes, - }); - - if (files.length === 0) { - throw new Error(ERROR_MESSAGES.NO_SOURCE_FILES(srcPattern)); - } - - return files; -} - -function 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/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/bundle.impl.ts b/packages/nx-infra-plugin/src/executors/bundle/bundle.impl.ts new file mode 100644 index 000000000000..f22714cf467c --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/bundle/bundle.impl.ts @@ -0,0 +1,260 @@ +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 { 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', + 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 ResolvedLicenseHeaders { + pkg: PackageJson; + templatePath: string; + options: BundleLicenseHeadersOption; +} + +interface ResolvedBundle { + projectRoot: string; + entries: string[]; + resolvedSourceDir: string; + resolvedOutDir: string; + mode: 'debug' | 'production'; + 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: async (options, { projectRoot }) => { + const { + entries, + sourceDir, + outDir, + mode, + webpackConfigPath = './webpack.config.js', + sourceMap = true, + applyLicenseHeaders, + } = 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); + const licenseHeaders = await resolveLicenseHeadersStep(projectRoot, applyLicenseHeaders); + + return { + projectRoot, + entries, + resolvedSourceDir, + resolvedOutDir, + mode, + sourceMap, + webpack, + baseConfig, + licenseHeaders, + }; + }, + 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)); + } + + 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/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/bundle/schema.json b/packages/nx-infra-plugin/src/executors/bundle/schema.json index 2751af84ce37..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": { @@ -29,8 +36,77 @@ "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"], - "additionalProperties": false + "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/clean/clean.impl.ts b/packages/nx-infra-plugin/src/executors/clean/clean.impl.ts new file mode 100644 index 000000000000..c71e35c9c181 --- /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)); +} + +export async function removeDirectoryCompletely(targetDirectory: string): Promise { + if (fs.existsSync(targetDirectory)) { + await rimrafAsync(targetDirectory); + } +} + +export async function removeDirectoryRespectingExclusions( + 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 removeDirectoryRespectingExclusions(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..70d6b58434eb 100644 --- a/packages/nx-infra-plugin/src/executors/clean/executor.ts +++ b/packages/nx-infra-plugin/src/executors/clean/executor.ts @@ -1,135 +1,2 @@ -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'; +export { removeDirectoryCompletely, removeDirectoryRespectingExclusions } from './clean.impl'; 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/compress.impl.ts b/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts new file mode 100644 index 000000000000..690240c99732 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/compress/compress.impl.ts @@ -0,0 +1,200 @@ +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, + loadProjectPackageJson, + normalizeEol, + readFileText, + writeFileText, +} from '../../utils/file-operations'; +import { CompressExecutorSchema, CompressMode, CompressModeName } from './schema'; +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; + +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 ?? DEFAULT_EULA_URL, 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)); +} + +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({ + 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, + applyLicenseHeaders: options.applyLicenseHeaders, + }; + }, + 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/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 cd35c3bd88e4..355157558de1 100644 --- a/packages/nx-infra-plugin/src/executors/compress/executor.ts +++ b/packages/nx-infra-plugin/src/executors/compress/executor.ts @@ -1,185 +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, normalizeGlobPathForWindows } from '../../utils/path-resolver'; -import { isWindowsOS, containsGlobPattern } from '../../utils/common'; -import { - readFileText, - writeFileText, - 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; - -function createCommentFilter(eulaUrl?: string) { - return function saveLicenseComments(_node: any, 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) - ); - }; -} - -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 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; -} - -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(stripDebugBlocks(content), eulaUrl), trailingNewline), - beautify: async (content, { eulaUrl, trailingNewline }) => - normalizeOutput(await runBeautify(content, eulaUrl), trailingNewline), - 'strip-debug': async (content) => stripDebugBlocks(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 toPosixIfWindows = (p: string) => (isWindowsOS() ? normalizeGlobPathForWindows(p) : p); - - const ignorePatterns = exclude?.map((p) => toPosixIfWindows(path.resolve(projectRoot, p))); - - const isExcluded = (absolutePath: string): boolean => - !!ignorePatterns?.some((pattern) => - minimatch(toPosixIfWindows(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 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/compress/schema.json b/packages/nx-infra-plugin/src/executors/compress/schema.json index 31bc8273e527..48638ece8130 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": { @@ -39,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/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 9cbfce85caf8..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,173 +1,7 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as path from 'path'; -import { glob } from 'glob'; -import { ConcatenateFilesExecutorSchema, TransformRule } 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'; - -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; - -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; - 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; -} - -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); - - 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 contents = await readAndProcessFiles(resolvedFiles, projectRoot, options); - const output = buildOutput(contents, options); - - const outputPath = path.resolve(projectRoot, options.outputFile); - await writeFileText(outputPath, output); - 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/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/copy-files.impl.ts b/packages/nx-infra-plugin/src/executors/copy-files/copy-files.impl.ts new file mode 100644 index 000000000000..88ae0fd6d411 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/copy-files/copy-files.impl.ts @@ -0,0 +1,158 @@ +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, + 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[]; + 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}`); +} + +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; +} + +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); + } + } + + await applyLicenseHeadersIfRequested(options.applyLicenseHeaders, projectRoot); + }, +}); + +export { ResolvedCopyFiles }; 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..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 @@ -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,144 @@ 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); + }); + + 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/executor.ts b/packages/nx-infra-plugin/src/executors/copy-files/executor.ts index 49e363919cd8..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,90 +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, normalizeGlobPathForWindows } from '../../utils/path-resolver'; -import { isWindowsOS, 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, -): Promise<{ success: boolean }> { - const globPattern = isWindowsOS() ? normalizeGlobPathForWindows(sourcePath) : sourcePath; - const files = await glob(globPattern, { nodir: true }); - - 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 } of options.files) { - const sourcePath = path.resolve(projectRoot, from); - const destPath = path.resolve(projectRoot, to); - - const result = containsGlobPattern(from) - ? await copyGlobPatternFiles(sourcePath, destPath) - : 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/copy-files/schema.json b/packages/nx-infra-plugin/src/executors/copy-files/schema.json index 08f65d656642..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": { @@ -14,10 +16,35 @@ "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"] } + }, + "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 9d081978ac5a..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,6 +1,12 @@ +import type { ApplyLicenseHeadersOption } from '../add-license-headers/schema'; + +export type { ApplyLicenseHeadersOption }; + export interface CopyFilesExecutorSchema { files: Array<{ from: string; to: string; + excludePatterns?: string[]; }>; + applyLicenseHeaders?: ApplyLicenseHeadersOption; } 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 0ce9a4e6c8c3..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,219 +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, normalizeGlobPathForWindows } from '../../utils/path-resolver'; -import { isWindowsOS } from '../../utils/common'; -import { logError } from '../../utils/error-handler'; -import { exists, ensureDir, writeFileText } from '../../utils/file-operations'; - -const ERROR_MESSAGES = { - ESM_DIR_NOT_FOUND: (dir: string) => `ESM directory does not exist: ${dir}`, - CJS_DIR_NOT_FOUND: (dir: string) => `CJS directory does not exist: ${dir}`, - FAILED_TO_CREATE_MANIFEST: 'Failed to create dual-mode manifest files', -} as const; - -function normalizePackagePath(p: string): string { - return p.replace(/\\/g, '/'); -} - -function createModuleConfig( - fileName: string, - fileDir: string, - esmFilePath: string, - srcDir: string, - generatedDtsFiles: string[], - sideEffectFinder: SideEffectFinder, -): string { - const isIndex = fileName === 'index.js'; - const relative = path.join('./', fileDir.replace(srcDir, ''), fileName); - const currentPath = isIndex ? path.join(relative, '../') : relative; - - const esmFile = path.relative(currentPath, path.join('./esm', relative)); - const cjsFile = path.relative(currentPath, path.join('./cjs', relative)); - - const dtsRelative = relative.replace(/\.js$/, '.d.ts'); - const realDtsPath = path.join(fileDir, fileName.replace(/\.js$/, '.d.ts')); - const hasGeneratedDts = generatedDtsFiles.includes( - normalizePackagePath(dtsRelative.replace(/^\.\//, '')), - ); - - const relativeEsmBase = normalizePackagePath(esmFile).match(/^.*\/esm\//)?.[0] || './esm/'; - let sideEffectFiles: string[] | false = false; - - try { - const moduleSideEffects = sideEffectFinder.getModuleSideEffectFiles(esmFilePath); - if (moduleSideEffects.length > 0) { - sideEffectFiles = moduleSideEffects.map((importPath) => - importPath.replace(/^.*\/esm\//, relativeEsmBase), - ); - } - } catch (e) { - logger.verbose(`Side effect analysis failed for ${esmFilePath}: ${e}`); - } - - const result: Record = { - sideEffects: sideEffectFiles, - main: normalizePackagePath(cjsFile), - module: normalizePackagePath(esmFile), - }; - - const hasRealDts = require('fs').existsSync(realDtsPath); - const hasDts = hasRealDts || hasGeneratedDts; - - if (hasDts) { - const typingFile = fileName.replace(/\.js$/, '.d.ts'); - result['typings'] = `${isIndex ? './' : '../'}${typingFile}`; - } - - return JSON.stringify(result, null, 2); -} - -function getPackageJsonOutputPath( - fileName: string, - fileDir: string, - srcDir: string, - outputDir: string, -): string { - const relativePath = fileDir.replace(srcDir, ''); - const baseName = path.basename(fileName, '.js'); - const isIndex = fileName === 'index.js'; - - if (isIndex) { - return path.join(outputDir, relativePath, 'package.json'); - } else { - return path.join(outputDir, relativePath, baseName, 'package.json'); - } -} - -async function validateDirectories(esmDir: string, cjsDir: string): Promise { - if (!(await exists(esmDir))) { - throw new Error(ERROR_MESSAGES.ESM_DIR_NOT_FOUND(esmDir)); - } - - if (!(await exists(cjsDir))) { - throw new Error(ERROR_MESSAGES.CJS_DIR_NOT_FOUND(cjsDir)); - } -} - -async function discoverJsFiles(esmDir: string): Promise { - const pattern = path.join(esmDir, '**/*.js'); - const globPattern = isWindowsOS() ? normalizeGlobPathForWindows(pattern) : pattern; - - return glob(globPattern, { - nodir: true, - ignore: ['**/node_modules/**'], - }); -} - -function shouldExcludeFile(relativeFilePath: string, excludePatterns: string[]): boolean { - return excludePatterns.some((pattern) => minimatch(relativeFilePath, pattern, { dot: true })); -} - -async function processFile( - file: string, - esmDir: string, - srcDir: string, - outputDir: string, - excludePatterns: string[], - generatedDtsFiles: string[], - sideEffectFinder: SideEffectFinder, -): Promise { - const fileName = path.basename(file); - const fileDir = path.dirname(file); - - const relativeFromEsm = path.relative(esmDir, fileDir); - const relativeFilePath = normalizePackagePath(path.join(relativeFromEsm, fileName)); - - if (shouldExcludeFile(relativeFilePath, excludePatterns)) { - logger.verbose(`Skipping excluded file: ${relativeFilePath}`); - return false; - } - - const correspondingSrcDir = path.join(srcDir, relativeFromEsm); - - const moduleConfig = createModuleConfig( - fileName, - correspondingSrcDir, - file, - srcDir, - generatedDtsFiles, - sideEffectFinder, - ); - - const packageJsonPath = getPackageJsonOutputPath( - fileName, - correspondingSrcDir, - srcDir, - outputDir, - ); - - await ensureDir(path.dirname(packageJsonPath)); - await writeFileText(packageJsonPath, moduleConfig); - - logger.verbose(`Created: ${path.relative(outputDir, packageJsonPath)}`); - return true; -} - -const runExecutor: PromiseExecutor = async ( - options, - context, -) => { - const projectRoot = resolveProjectPath(context); - - const esmDir = path.resolve(projectRoot, options.esmDir); - const cjsDir = path.resolve(projectRoot, options.cjsDir); - const outputDir = path.resolve(projectRoot, options.outputDir); - const srcDir = path.resolve(projectRoot, options.srcDir); - const excludePatterns = options.excludePatterns || []; - const generatedDtsFiles = options.generatedDtsFiles || []; - - logger.verbose(`Creating dual-mode manifest files...`); - logger.verbose(` ESM dir: ${esmDir}`); - logger.verbose(` CJS dir: ${cjsDir}`); - logger.verbose(` Output dir: ${outputDir}`); - logger.verbose(` Source dir: ${srcDir}`); - logger.verbose(` Exclude patterns: ${excludePatterns.join(', ') || '(none)'}`); - - try { - await validateDirectories(esmDir, cjsDir); - - const files = await discoverJsFiles(esmDir); - - if (files.length === 0) { - logger.warn(`No JS files found in ESM directory: ${esmDir}`); - return { success: true }; - } - - logger.verbose(`Found ${files.length} JS files to process`); - - const sideEffectFinder = new SideEffectFinder(); - let createdCount = 0; - - for (const file of files) { - const created = await processFile( - file, - esmDir, - srcDir, - outputDir, - excludePatterns, - generatedDtsFiles, - sideEffectFinder, - ); - if (created) { - createdCount++; - } - } - - logger.info(`Created ${createdCount} package.json manifest files`); - - return { success: true }; - } catch (error) { - logError(ERROR_MESSAGES.FAILED_TO_CREATE_MANIFEST, error); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './create-dual-mode-manifest.impl'; 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/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 new file mode 100644 index 000000000000..68cbdc348c82 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.e2e.spec.ts @@ -0,0 +1,101 @@ +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-comment banner 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).not.toContain('declare global'); + expect(artifactContent).toContain('interface JQuery'); + expect(artifactContent).toContain('EventObject'); + }); + + 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); + + const packageContent = await readFileText( + path.join(projectDir, 'artifacts', 'npm', 'devextreme', 'bundles', 'dx.all.d.ts'), + ); + + expect(packageContent).toMatch(/^\/\*\*/); + expect(packageContent).toContain('\nexport default DevExpress;'); + expect(packageContent).toContain('interface JQuery'); + expect(packageContent).not.toContain('dxButton()'); + }); +}); 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..cb650aabca55 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/executor.ts @@ -0,0 +1 @@ +export { default } from './dts-bundle.impl'; 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..abee1ba68473 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-bundle/schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/schema", + "title": "Dts Bundle Executor Schema", + "description": "Assemble TypeScript declaration bundle files from source", + "type": "object", + "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"] +} 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..f8221b3f84a5 --- /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/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 new file mode 100644 index 000000000000..4a1d0108115c --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.e2e.spec.ts @@ -0,0 +1,156 @@ +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 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', + templatesDir: './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'), + HOVER_TEMPLATE, + ); + await writeFileText( + path.join(projectDir, 'build', 'npm-templates', 'bundles', 'dx.all.js'), + DX_ALL_JS_TEMPLATE, + ); + await writeFileText( + path.join(projectDir, 'build', 'npm-templates', 'integration', 'jquery.d.ts'), + "import 'jquery';", + ); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + 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); + + const outDir = path.join(projectDir, 'artifacts', 'npm', 'devextreme'); + + const accordionContent = await readFileText(path.join(outDir, 'accordion.d.ts')); + expect(accordionContent).toMatch(/^\/\*\*/); + expect(accordionContent).toContain('accordion'); + + const hoverContent = await readFileText(path.join(outDir, 'events', 'hover.d.ts')); + expect(hoverContent).toContain(HOVER_TEMPLATE); + + const dxAllJsContent = await readFileText(path.join(outDir, 'bundles', 'dx.all.js')); + expect(dxAllJsContent).toContain('DevExtreme (dx.all.js)'); + expect(dxAllJsContent).not.toContain('DevExtreme (bundles/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 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( + 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); + }); + + it('should use the bundled template when licenseTemplateFile is omitted', async () => { + const options: DtsModulesExecutorSchema = { + sourceDir: './js', + outputDir: './artifacts/npm/devextreme', + templatesDir: './build/npm-templates', + }; + + 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(/^\/\*\*/); + }); +}); 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..9ca804ec2bac --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-modules/executor.ts @@ -0,0 +1 @@ +export { default } from './dts-modules.impl'; 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..7fe591b0c638 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-modules/schema.json @@ -0,0 +1,29 @@ +{ + "$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", + "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)." + }, + "templatesDir": { + "type": "string", + "description": "Directory containing static 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", "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 new file mode 100644 index 000000000000..6ab3163c658c --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/dts-modules/schema.ts @@ -0,0 +1,7 @@ +export interface DtsModulesExecutorSchema { + sourceDir: string; + outputDir: string; + templatesDir: string; + licenseTemplateFile?: string; + eulaUrl?: string; +} 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-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/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/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/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/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/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..100ea6fa01d8 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/license-check/schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/schema", + "title": "License Check Executor Schema", + "description": "Verify embedded license notices in built artifacts", + "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[]; +} 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/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..d6498442879c --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/localization/localization.impl.ts @@ -0,0 +1,508 @@ +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 { + 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: { + 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); + } +} + +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; + messageTemplate: string; + messageOutputDir: string; + generatedTemplate: string; + cldrDataOutputDir: string; + defaultMessagesOutputDir: string; + skipCldrGeneration: boolean; + skipMessageGeneration: boolean; + lintGeneratedFiles: boolean; + applyLicenseHeaders?: ApplyLicenseHeadersOption; +} + +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, + applyLicenseHeaders: options.applyLicenseHeaders, + }; + }, + 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}`); + } + + 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..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": { @@ -48,7 +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 new file mode 100644 index 000000000000..fa095efb578b --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.e2e.spec.ts @@ -0,0 +1,303 @@ +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/', + srcExcludes: ['bundles/**/*'], + distExcludes: ['js/jquery*'], + excludeLicenseValidator: '**/license/license_validation_internal.js', +}; + +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 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 }); + 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'); + 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 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'); + expect(fs.existsSync(path.join(outDir, 'license', 'LICENSE.txt'))).toBe(true); + 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 }); + + await writeFileText(path.join(artifactsDir, 'js', 'dx.all.js'), 'var dx = {};'); + await writeFileText(path.join(artifactsDir, 'js', 'jquery.js'), 'var $ = {};'); + + 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); + }); + + 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); + }); + + 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/executor.ts b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts new file mode 100644 index 000000000000..301e8a971ca6 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/executor.ts @@ -0,0 +1 @@ +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..c70539e304cc --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/npm-assemble.impl.ts @@ -0,0 +1,276 @@ +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, + loadProjectPackageJson, + 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, + NpmAssembleFlattenStep, + NpmAssembleMetadataFile, + NpmAssembleRename, +} from './schema'; + +function buildSrcExcludes( + baseExcludes: readonly string[], + excludeLicenseValidator: string | undefined, +): string[] { + return excludeLicenseValidator ? [...baseExcludes, excludeLicenseValidator] : [...baseExcludes]; +} + +function buildSrcHeaderExcludes(srcExcludes: readonly string[]): string[] { + return [...srcExcludes, 'dist/**/*', 'bin/**/*', 'license/**/*']; +} + +async function copySourceJs( + transpiledDir: string, + outputDir: string, + excludes: readonly string[], +): Promise { + await copyDirectory(transpiledDir, outputDir, { + include: ['**/*.js'], + exclude: [...excludes], + }); +} + +async function applyRenameLicenseValidator( + outputDir: string, + rename: NpmAssembleRename, +): Promise { + const cwd = toPosixPath(outputDir); + const matches = await glob(rename.fromGlob, { cwd, absolute: true }); + await Promise.all( + matches.map(async (sourcePath) => { + const targetPath = path.join(path.dirname(sourcePath), rename.toBasename); + try { + await fs.rename(sourcePath, targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + }), + ); +} + +async function copyEsmPackageJsonFiles( + transpiledDir: string, + outputDir: string, + nestedPackageJsonExcludes: readonly string[], +): Promise { + await copyDirectory(transpiledDir, outputDir, { + include: ['**/*.json'], + exclude: [...nestedPackageJsonExcludes], + }); +} + +async function copyJsSrcJsonFiles( + jsSrcDir: string, + outputDir: string, + nestedPackageJsonExcludes: readonly string[], +): Promise { + await copyDirectory(jsSrcDir, outputDir, { + include: ['**/*.json'], + exclude: [...nestedPackageJsonExcludes], + }); +} + +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); + const lfContent = content.replace(/\r\n/g, '\n'); + const withTrailingLf = lfContent.endsWith('\n') ? lfContent : `${lfContent}\n`; + await writeFileText(destination, withTrailingLf); + }), + ); +} + +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, + excludes: readonly string[], +): Promise { + await copyDirectory(artifactsDir, path.join(outputDir, 'dist'), { + include: ['**/*'], + exclude: [...excludes], + }); +} + +interface ResolvedMetadataFile { + from: string; + to: string; +} + +interface ResolvedFlattenStep { + from: string; + to: string; +} + +interface ResolvedNpmAssemble { + pkg: PackageJson; + templatePath: string; + eulaUrl: string; + transpiledDir: string; + jsSrcDir: string; + licenseSrcDir: string; + npmBinDir: string; + webpackConfigSrc: string; + artifactsDir: string; + outputDir: string; + metadataFiles: ResolvedMetadataFile[]; + flattenSteps: ResolvedFlattenStep[]; + srcExcludes: string[]; + srcHeaderExcludes: string[]; + distExcludes: readonly string[]; + nestedPackageJsonExcludes: readonly string[]; + renameLicenseValidator?: NpmAssembleRename; +} + +function resolveMetadataFiles( + entries: NpmAssembleMetadataFile[] | undefined, + projectRoot: string, + outputDir: string, +): ResolvedMetadataFile[] { + if (!entries) { + return []; + } + return entries.map((entry) => ({ + 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({ + name: 'NpmAssemble', + resolve: async (options, { projectRoot }) => { + 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, + 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, + metadataFiles: resolveMetadataFiles(options.metadataFiles, projectRoot, outputDir), + flattenSteps: resolveFlattenSteps(options.flatten, projectRoot, outputDir), + srcExcludes, + srcHeaderExcludes: buildSrcHeaderExcludes(srcExcludes), + distExcludes: options.distExcludes ?? [], + nestedPackageJsonExcludes: options.nestedPackageJsonExcludes ?? [], + renameLicenseValidator: options.renameLicenseValidator, + }; + }, + run: async (resolved) => { + const webpackConfigDestination = path.join( + resolved.outputDir, + 'bin', + path.basename(resolved.webpackConfigSrc), + ); + + await Promise.all([ + 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, resolved.distExcludes), + ]); + logger.verbose('Assembled npm package contents'); + + if (resolved.renameLicenseValidator) { + await applyRenameLicenseValidator(resolved.outputDir, resolved.renameLicenseValidator); + logger.verbose('Renamed license validator'); + } + + await applyLicenseHeadersToDirectory({ + targetDir: resolved.outputDir, + pkg: resolved.pkg, + templatePath: resolved.templatePath, + eulaUrl: resolved.eulaUrl, + commentType: '*', + includePatterns: ['**/*.js'], + excludePatterns: resolved.srcHeaderExcludes, + 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 new file mode 100644 index 000000000000..70fcf0aaca23 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.json @@ -0,0 +1,105 @@ +{ + "$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", + "properties": { + "transpiledDir": { + "type": "string", + "description": "Transpiled ESM+CJS sources dir (relative to project root)." + }, + "jsSrcDir": { + "type": "string", + "description": "JS source dir for JSON copy (relative to project root)." + }, + "licenseSrcDir": { + "type": "string", + "description": "License files dir (relative to project root)." + }, + "npmBinDir": { + "type": "string", + "description": "Npm bin scripts dir (relative to project root)." + }, + "webpackConfig": { + "type": "string", + "description": "Webpack config to copy into bin/ (relative to project root)." + }, + "artifactsDir": { + "type": "string", + "description": "Artifacts dir for dist copy (relative to project root)." + }, + "outputDir": { + "type": "string", + "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": "License header template (relative to project root)." + }, + "eulaUrl": { + "type": "string", + "description": "EULA URL embedded in the license header." + }, + "metadataFiles": { + "type": "array", + "description": "Files copied after assembly. 'from' resolved against projectRoot, 'to' against outputDir.", + "items": { + "type": "object", + "properties": { + "from": { "type": "string", "description": "Source path (vs projectRoot)." }, + "to": { "type": "string", "description": "Destination path (vs outputDir)." } + }, + "required": ["from", "to"] + } + }, + "flatten": { + "type": "array", + "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 dir inside outputDir." }, + "to": { "type": "string", "description": "Destination dir (vs projectRoot)." } + }, + "required": ["from", "to"] + } + } + }, + "required": [ + "transpiledDir", + "jsSrcDir", + "licenseSrcDir", + "npmBinDir", + "webpackConfig", + "artifactsDir", + "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 new file mode 100644 index 000000000000..ca0f74261f54 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/npm-assemble/schema.ts @@ -0,0 +1,33 @@ +export interface NpmAssembleMetadataFile { + from: string; + to: string; +} + +export interface NpmAssembleFlattenStep { + from: string; + to: string; +} + +export interface NpmAssembleRename { + fromGlob: string; + toBasename: string; +} + +export interface NpmAssembleExecutorSchema { + transpiledDir: string; + jsSrcDir: string; + licenseSrcDir: string; + npmBinDir: string; + webpackConfig: string; + artifactsDir: string; + outputDir: string; + srcExcludes?: string[]; + distExcludes?: string[]; + nestedPackageJsonExcludes?: string[]; + excludeLicenseValidator?: string; + renameLicenseValidator?: NpmAssembleRename; + licenseTemplateFile?: string; + eulaUrl?: string; + metadataFiles?: NpmAssembleMetadataFile[]; + flatten?: NpmAssembleFlattenStep[]; +} 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/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/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/prepare-package-json/executor.e2e.spec.ts index d11d2c288583..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,54 +50,140 @@ describe('PreparePackageJsonExecutor E2E', () => { cleanupTempDir(tempDir); }); - describe('publishConfig removal', () => { - it('should remove publishConfig from package.json', async () => { - const options: NpmPackageExecutorSchema = { - distDirectory: './npm', - }; + it('should remove publishConfig by default and preserve all other fields', async () => { + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + }; - const result = await executor(options, context); + const result = await executor(options, context); - expect(result.success).toBe(true); + 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 projectDir = path.join(tempDir, 'packages', 'test-lib'); + const distPackage = JSON.parse( + await readFileText(path.join(projectDir, 'npm', 'package.json')), + ); + + expect(distPackage.publishConfig).toBeUndefined(); + 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 () => { + 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(distPackage.publishConfig).toBeUndefined(); + 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', }); - it('should preserve all other fields when removing publishConfig', async () => { - const options: NpmPackageExecutorSchema = { - distDirectory: './npm', - }; + const options: NpmPackageExecutorSchema = { + distDirectory: './npm', + versionFrom: './version-source.json', + }; - await executor(options, context); + 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.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 distPkg = JSON.parse(await readFileText(path.join(projectDir, 'npm', 'package.json'))); + + expect(distPkg.version).toBe('9.8.7'); + }); + + it('should apply renameInternalPattern after setName', 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 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..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,44 +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'; - -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); - delete pkg[PUBLISH_CONFIG_FIELD]; - - const distPackageJson = path.join(distDirectory, PACKAGE_JSON_FILE); - await writeJson(distPackageJson, pkg, 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-package-json/schema.json b/packages/nx-infra-plugin/src/executors/prepare-package-json/schema.json index d555d19c5d7f..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": { @@ -10,7 +13,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; } 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/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/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts new file mode 100644 index 000000000000..bebf5fbea714 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts @@ -0,0 +1,86 @@ +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); + }); +}); 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..ac5b9358fc1c --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.ts @@ -0,0 +1 @@ +export { default } from './scss-assemble.impl'; 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..3efa0803ccff --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json @@ -0,0 +1,17 @@ +{ + "$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", + "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; +} 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/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..4246a88e7ea2 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/executor.e2e.spec.ts @@ -0,0 +1,149 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from '@nx/devkit'; +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<{ success: boolean }> => { + const options: StateManagerOptimizeExecutorSchema = { transpiledDirs: [TRANSPILED_DIR] }; + return 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); + + 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/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..aa1084c7ae49 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/schema", + "title": "State Manager Optimize Executor Schema", + "description": "Optimize state_manager modules for production builds", + "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..14e45dcc7aa2 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/state-manager-optimize/state-manager-optimize.impl.ts @@ -0,0 +1,89 @@ +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_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'; + +export 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; +} + +function isCjsFile(filePath: string): boolean { + return toPosixPath(filePath).includes('/cjs/'); +} + +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(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 { + 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)`, + ); + + const counts = await Promise.all( + resolved.transpiledDirs.map((transpiledDir) => + 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.`, + ); + } + }, +}); 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/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/schema.json b/packages/nx-infra-plugin/src/executors/vectormap/schema.json index 0cc4123e6554..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": { @@ -33,6 +35,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 new file mode 100644 index 000000000000..3d83e898ca22 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/vectormap/vectormap.impl.ts @@ -0,0 +1,270 @@ +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 { 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[]; + 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); +} + +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; + settingsFile: string; + sourcesDir: string; + sourcesSettingsFile: string; + resolvedUtilsOutDir: string; + dataOutDir: string; + resolvedUtilsTemplatePath: string; + resolvedDataTemplatePath: string; + applyLicenseHeaders?: ApplyLicenseHeadersOption; +} + +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), + applyLicenseHeaders: options.applyLicenseHeaders, + }; + }, + 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`); + + await applyLicenseHeadersIfRequested( + resolved.applyLicenseHeaders, + resolved.projectRoot, + resolved.resolvedUtilsOutDir, + ); + }, +}); 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/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/license-banner.e2e.spec.ts b/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts new file mode 100644 index 000000000000..d8afdb77401b --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/license-banner.e2e.spec.ts @@ -0,0 +1,107 @@ +import * as path from 'path'; +import { buildLicenseBannerRenderer, extractGitHubUrl } from './license-banner'; +import { createTempDir, cleanupTempDir } from './test-utils'; +import { writeFileText } from './file-operations'; + +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', + ); + expect(result).toBe('https://github.com/foo/bar'); + }); + + 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", + ); + }); + + 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 new file mode 100644 index 000000000000..408e8d5711d5 --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/license-banner.ts @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import { readFileText, writeFileText } from './file-operations'; +import type { PackageJson } from './types'; + +export interface LicenseBannerOptions { + templatePath: 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, githubUrl = '' } = opts; + const resolvedVersion = opts.version ?? pkg.version; + const now = new Date(); + + const templateText = (await readFileText(templatePath)).replace(/\r\n/g, '\n'); + const compiled = _.template(templateText); + return (fileRelative: string) => + compiled({ + commentType, + version: resolvedVersion, + eula: eulaUrl, + file: { relative: fileRelative }, + date: now.toDateString(), + year: now.getFullYear(), + pkg, + githubUrl, + }); +} + +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: { + separator?: string; + prependAfterLicense?: string; + } = {}, +): Promise { + const content = await readFileText(filePath); + const separator = options.separator ?? ''; + const prepend = options.prependAfterLicense ?? ''; + await writeFileText(filePath, banner + separator + prepend + content); +} diff --git a/packages/nx-infra-plugin/src/utils/path-resolver.ts b/packages/nx-infra-plugin/src/utils/path-resolver.ts index c52834cbe7d4..3246c9d7156e 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,23 @@ 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; +} + +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; +} 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;