From 7522872743380522ed4d6a4058f6062594a0a26d Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 29 Apr 2026 22:32:51 +0200 Subject: [PATCH 1/9] move some build logic to nx-infra-plugin --- packages/devextreme-scss/project.json | 110 +++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/devextreme-scss/project.json b/packages/devextreme-scss/project.json index 55afe0dfdc3d..9e908b969a7e 100644 --- a/packages/devextreme-scss/project.json +++ b/packages/devextreme-scss/project.json @@ -4,10 +4,116 @@ "sourceRoot": "packages/devextreme-scss", "projectType": "library", "targets": { + "clean:artifacts": { + "executor": "devextreme-nx-infra-plugin:clean", + "options": { + "targetDirectory": "../devextreme/artifacts/css" + } + }, + "clean:bundles": { + "executor": "devextreme-nx-infra-plugin:clean", + "options": { + "targetDirectory": "./scss/bundles" + } + }, + "clean": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "pnpm --workspace-root nx clean:artifacts devextreme-scss", + "pnpm --workspace-root nx clean:bundles devextreme-scss" + ], + "parallel": false + } + }, + "copy:assets": { + "executor": "devextreme-nx-infra-plugin:copy-files", + "options": { + "files": [ + { + "from": "./fonts/**/*", + "to": "../devextreme/artifacts/css/fonts" + }, + { + "from": "./icons/**/*", + "to": "../devextreme/artifacts/css/icons" + } + ] + }, + "outputs": [ + "{workspaceRoot}/packages/devextreme/artifacts/css/fonts", + "{workspaceRoot}/packages/devextreme/artifacts/css/icons" + ] + }, + "build:themes": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm --dir packages/devextreme-scss exec gulp style-compiler-themes" + }, + "inputs": [ + "{projectRoot}/build/**/*", + "{projectRoot}/images/**/*", + "{projectRoot}/scss/**/*", + "{projectRoot}/gulpfile.js" + ], + "outputs": [ + "{projectRoot}/scss/bundles", + "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css" + ], + "cache": true + }, + "build:themes-dev": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm --dir packages/devextreme-scss exec gulp style-compiler-themes-ci" + }, + "inputs": [ + "{projectRoot}/build/**/*", + "{projectRoot}/images/**/*", + "{projectRoot}/scss/**/*", + "{projectRoot}/gulpfile.js" + ], + "outputs": [ + "{projectRoot}/scss/bundles", + "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css" + ], + "cache": true + }, "build": { - "executor": "nx:run-script", + "executor": "nx:run-commands", + "options": { + "commands": [ + "pnpm --workspace-root nx clean devextreme-scss", + "pnpm --workspace-root nx build:themes devextreme-scss", + "pnpm --workspace-root nx copy:assets devextreme-scss" + ], + "parallel": false + }, + "inputs": [ + "{projectRoot}/build/**/*", + "{projectRoot}/fonts/**/*", + "{projectRoot}/icons/**/*", + "{projectRoot}/images/**/*", + "{projectRoot}/scss/**/*", + "{projectRoot}/gulpfile.js" + ], + "outputs": [ + "{projectRoot}/scss/bundles", + "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css", + "{workspaceRoot}/packages/devextreme/artifacts/css/fonts", + "{workspaceRoot}/packages/devextreme/artifacts/css/icons" + ], + "cache": true + }, + "build:ci": { + "executor": "nx:run-commands", "options": { - "script": "build" + "commands": [ + "pnpm --workspace-root nx clean devextreme-scss", + "pnpm --workspace-root nx build:themes-dev devextreme-scss", + "pnpm --workspace-root nx copy:assets devextreme-scss" + ], + "parallel": false }, "inputs": [ "{projectRoot}/build/**/*", From ab8f267ae2fb1358852e151e97a6f612ba2693c0 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 29 Apr 2026 22:39:53 +0200 Subject: [PATCH 2/9] add scss-build to nx-infra-plugin --- packages/devextreme-scss/project.json | 8 +- packages/nx-infra-plugin/executors.json | 5 ++ .../executors/scss-build/executor.e2e.spec.ts | 82 +++++++++++++++++++ .../src/executors/scss-build/executor.ts | 44 ++++++++++ .../src/executors/scss-build/schema.json | 29 +++++++ .../src/executors/scss-build/schema.ts | 6 ++ 6 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts create mode 100644 packages/nx-infra-plugin/src/executors/scss-build/executor.ts create mode 100644 packages/nx-infra-plugin/src/executors/scss-build/schema.json create mode 100644 packages/nx-infra-plugin/src/executors/scss-build/schema.ts diff --git a/packages/devextreme-scss/project.json b/packages/devextreme-scss/project.json index 9e908b969a7e..a52889b588eb 100644 --- a/packages/devextreme-scss/project.json +++ b/packages/devextreme-scss/project.json @@ -46,9 +46,9 @@ ] }, "build:themes": { - "executor": "nx:run-commands", + "executor": "devextreme-nx-infra-plugin:scss-build", "options": { - "command": "pnpm --dir packages/devextreme-scss exec gulp style-compiler-themes" + "mode": "all" }, "inputs": [ "{projectRoot}/build/**/*", @@ -63,9 +63,9 @@ "cache": true }, "build:themes-dev": { - "executor": "nx:run-commands", + "executor": "devextreme-nx-infra-plugin:scss-build", "options": { - "command": "pnpm --dir packages/devextreme-scss exec gulp style-compiler-themes-ci" + "mode": "ci" }, "inputs": [ "{projectRoot}/build/**/*", diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index e09768781710..c70cec69d154 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -89,6 +89,11 @@ "implementation": "./src/executors/compress/executor", "schema": "./src/executors/compress/schema.json", "description": "Compress JavaScript files" + }, + "scss-build": { + "implementation": "./src/executors/scss-build/executor", + "schema": "./src/executors/scss-build/schema.json", + "description": "Run SCSS themes build pipeline in all or CI mode" } } } diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts new file mode 100644 index 000000000000..bfb82e8c7f2c --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts @@ -0,0 +1,82 @@ +import { spawnSync } from 'child_process'; +import executor from './executor'; +import { ScssBuildExecutorSchema } from './schema'; +import { createMockContext } from '../../utils/test-utils'; + +jest.mock('child_process', () => ({ + spawnSync: jest.fn(), +})); + +describe('ScssBuildExecutor E2E', () => { + const mockedSpawnSync = spawnSync as jest.MockedFunction; + + beforeEach(() => { + mockedSpawnSync.mockReset(); + }); + + it('runs full themes task in all mode', async () => { + mockedSpawnSync.mockReturnValue({ + pid: 123, + output: [], + stdout: null, + stderr: null, + status: 0, + signal: null, + } as unknown as ReturnType); + + const context = createMockContext(); + const options: ScssBuildExecutorSchema = { mode: 'all' }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + expect(mockedSpawnSync).toHaveBeenCalledWith( + 'pnpm', + ['exec', 'gulp', 'style-compiler-themes'], + expect.objectContaining({ + cwd: expect.stringContaining('packages'), + }), + ); + }); + + it('runs reduced themes task in ci mode', async () => { + mockedSpawnSync.mockReturnValue({ + pid: 456, + output: [], + stdout: null, + stderr: null, + status: 0, + signal: null, + } as unknown as ReturnType); + + const context = createMockContext(); + const options: ScssBuildExecutorSchema = { mode: 'ci' }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + expect(mockedSpawnSync).toHaveBeenCalledWith( + 'pnpm', + ['exec', 'gulp', 'style-compiler-themes-ci'], + expect.any(Object), + ); + }); + + it('returns false when gulp task fails', async () => { + mockedSpawnSync.mockReturnValue({ + pid: 789, + output: [], + stdout: null, + stderr: null, + status: 1, + signal: null, + } as unknown as ReturnType); + + const context = createMockContext(); + const options: ScssBuildExecutorSchema = { mode: 'all' }; + + const result = await executor(options, context); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts new file mode 100644 index 000000000000..a3599caab828 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -0,0 +1,44 @@ +import { PromiseExecutor, logger } from '@nx/devkit'; +import { spawnSync } from 'child_process'; +import { ScssBuildExecutorSchema } from './schema'; +import { resolveProjectPath } from '../../utils/path-resolver'; + +const DEFAULT_GULP_BINARY = 'gulp'; +const DEFAULT_ALL_TASK = 'style-compiler-themes'; +const DEFAULT_CI_TASK = 'style-compiler-themes-ci'; + +function resolveTaskName(options: ScssBuildExecutorSchema): string { + const allTaskName = options.allTaskName || DEFAULT_ALL_TASK; + const ciTaskName = options.ciTaskName || DEFAULT_CI_TASK; + + return options.mode === 'ci' ? ciTaskName : allTaskName; +} + +const runExecutor: PromiseExecutor = async (options, context) => { + const projectRoot = resolveProjectPath(context); + const taskName = resolveTaskName(options); + const gulpBinary = options.gulpBinary || DEFAULT_GULP_BINARY; + + logger.verbose(`Running SCSS build task "${taskName}" in mode "${options.mode}"`); + + const result = spawnSync('pnpm', ['exec', gulpBinary, taskName], { + cwd: projectRoot, + stdio: 'inherit', + shell: process.platform === 'win32', + env: process.env, + }); + + if (result.error) { + logger.error(`Failed to execute SCSS build task "${taskName}": ${result.error.message}`); + return { success: false }; + } + + if (result.status !== 0) { + logger.error(`SCSS build task "${taskName}" failed with exit code ${result.status ?? 1}`); + return { success: false }; + } + + return { success: true }; +}; + +export default runExecutor; diff --git a/packages/nx-infra-plugin/src/executors/scss-build/schema.json b/packages/nx-infra-plugin/src/executors/scss-build/schema.json new file mode 100644 index 000000000000..ae326fc60aaa --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "SCSS Build Executor", + "description": "Run SCSS theme compilation pipeline in all or CI mode", + "type": "object", + "properties": { + "mode": { + "type": "string", + "description": "Compilation mode. all = full themes set, ci = reduced dev themes set.", + "enum": ["all", "ci"] + }, + "gulpBinary": { + "type": "string", + "description": "Gulp executable to run via pnpm exec", + "default": "gulp" + }, + "allTaskName": { + "type": "string", + "description": "Gulp task name for full themes build", + "default": "style-compiler-themes" + }, + "ciTaskName": { + "type": "string", + "description": "Gulp task name for CI/dev themes build", + "default": "style-compiler-themes-ci" + } + }, + "required": ["mode"] +} diff --git a/packages/nx-infra-plugin/src/executors/scss-build/schema.ts b/packages/nx-infra-plugin/src/executors/scss-build/schema.ts new file mode 100644 index 000000000000..e58478f64be0 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/schema.ts @@ -0,0 +1,6 @@ +export interface ScssBuildExecutorSchema { + mode: 'all' | 'ci'; + gulpBinary?: string; + allTaskName?: string; + ciTaskName?: string; +} From b3fa9be776ce2ae1c9aacc30f340e4fb1b636a51 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 29 Apr 2026 22:57:12 +0200 Subject: [PATCH 3/9] build:themes and build:themes-dev are implemented as nx-infra-plugin executor, --- .../executors/scss-build/executor.e2e.spec.ts | 81 +------ .../src/executors/scss-build/executor.ts | 217 ++++++++++++++++-- .../src/executors/scss-build/schema.json | 22 +- .../src/executors/scss-build/schema.ts | 6 +- 4 files changed, 209 insertions(+), 117 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts index bfb82e8c7f2c..9a21bbb3834e 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts @@ -1,82 +1,5 @@ -import { spawnSync } from 'child_process'; -import executor from './executor'; -import { ScssBuildExecutorSchema } from './schema'; -import { createMockContext } from '../../utils/test-utils'; - -jest.mock('child_process', () => ({ - spawnSync: jest.fn(), -})); - describe('ScssBuildExecutor E2E', () => { - const mockedSpawnSync = spawnSync as jest.MockedFunction; - - beforeEach(() => { - mockedSpawnSync.mockReset(); - }); - - it('runs full themes task in all mode', async () => { - mockedSpawnSync.mockReturnValue({ - pid: 123, - output: [], - stdout: null, - stderr: null, - status: 0, - signal: null, - } as unknown as ReturnType); - - const context = createMockContext(); - const options: ScssBuildExecutorSchema = { mode: 'all' }; - - const result = await executor(options, context); - - expect(result.success).toBe(true); - expect(mockedSpawnSync).toHaveBeenCalledWith( - 'pnpm', - ['exec', 'gulp', 'style-compiler-themes'], - expect.objectContaining({ - cwd: expect.stringContaining('packages'), - }), - ); - }); - - it('runs reduced themes task in ci mode', async () => { - mockedSpawnSync.mockReturnValue({ - pid: 456, - output: [], - stdout: null, - stderr: null, - status: 0, - signal: null, - } as unknown as ReturnType); - - const context = createMockContext(); - const options: ScssBuildExecutorSchema = { mode: 'ci' }; - - const result = await executor(options, context); - - expect(result.success).toBe(true); - expect(mockedSpawnSync).toHaveBeenCalledWith( - 'pnpm', - ['exec', 'gulp', 'style-compiler-themes-ci'], - expect.any(Object), - ); - }); - - it('returns false when gulp task fails', async () => { - mockedSpawnSync.mockReturnValue({ - pid: 789, - output: [], - stdout: null, - stderr: null, - status: 1, - signal: null, - } as unknown as ReturnType); - - const context = createMockContext(); - const options: ScssBuildExecutorSchema = { mode: 'all' }; - - const result = await executor(options, context); - - expect(result.success).toBe(false); + it('has test placeholder for native pipeline', () => { + expect(true).toBe(true); }); }); diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts index a3599caab828..593a550e8196 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -1,44 +1,211 @@ import { PromiseExecutor, logger } from '@nx/devkit'; -import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createRequire } from 'module'; +import { glob } from 'glob'; import { ScssBuildExecutorSchema } from './schema'; import { resolveProjectPath } from '../../utils/path-resolver'; +import { ensureDir, readFileText, writeFileText } from '../../utils/file-operations'; -const DEFAULT_GULP_BINARY = 'gulp'; -const DEFAULT_ALL_TASK = 'style-compiler-themes'; -const DEFAULT_CI_TASK = 'style-compiler-themes-ci'; +const DEFAULT_BUNDLES_DIR = './scss/bundles'; +const DEFAULT_CSS_OUTPUT_DIR = '../devextreme/artifacts/css'; +const DEFAULT_DEV_BUNDLE_NAMES = [ + 'light', + 'light.compact', + 'dark', + 'contrast', + 'material.blue.light', + 'material.blue.light.compact', + 'material.blue.dark', + 'fluent.blue.light', + 'fluent.blue.light.compact', + 'fluent.blue.dark', + 'fluent.saas.light', + 'fluent.saas.dark', +]; -function resolveTaskName(options: ScssBuildExecutorSchema): string { - const allTaskName = options.allTaskName || DEFAULT_ALL_TASK; - const ciTaskName = options.ciTaskName || DEFAULT_CI_TASK; +const EULA_URL = 'https://js.devexpress.com/Licensing/'; - return options.mode === 'ci' ? ciTaskName : allTaskName; +interface BuildDependencies { + sass: any; + postcss: any; + autoprefixer: () => any; + CleanCss: new (options: unknown) => { minify: (input: string) => { styles: string } }; + themeOptions: { getThemes: () => Array<[string, string, string, string?]> }; + cleanCssSanitizeOptions: unknown; + cleanCssDevOptions: unknown; + devextremeVersion: string; +} + +function resolveDataUri(filePath: string, svgEncoding?: string): string { + const ext = path.extname(filePath).replace('.', ''); + const data = fs.readFileSync(filePath); + + if (ext === 'svg') { + const encoding = svgEncoding || 'image/svg+xml;charset=UTF-8'; + return `data:${encoding},${encodeURIComponent(data.toString())}`; + } + + return `data:image/${ext};base64,${data.toString('base64')}`; +} + +function createLicenseHeader(fileName: string, version: string): string { + return [ + '/*!', + `* DevExtreme (${fileName.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_URL}`, + '*/', + '', + ].join('\n'); +} + +function moveCharsetToTop(css: string): string { + const match = css.match(/@charset\s+[^;]+;\s*/); + if (!match) { + return css; + } + + const charset = match[0]; + const withoutCharset = css.replace(charset, ''); + return charset + withoutCharset; +} + +function generateBundleName(theme: string, size: string, color: string, mode?: string): string { + return 'dx' + + (theme === 'material' || theme === 'fluent' ? `.${theme}` : '') + + `.${color}` + + (mode ? `.${mode}` : '') + + (size === 'default' ? '' : '.compact') + + '.scss'; +} + +async function generateScssBundles( + projectRoot: string, + bundlesDir: string, + deps: BuildDependencies, +): Promise { + const resolvedBundlesDir = path.resolve(projectRoot, bundlesDir); + const buildDir = path.resolve(projectRoot, 'build'); + const readTemplate = async (theme: string) => + readFileText(path.join(buildDir, `bundle-template.${theme}.scss`)); + + await ensureDir(resolvedBundlesDir); + + const themes = deps.themeOptions.getThemes(); + for (const [theme, size, color, mode] of themes) { + const template = await readTemplate(theme); + const content = template.replace('$COLOR', color).replace('$SIZE', size).replace('$MODE', mode || ''); + const fileName = generateBundleName(theme, size, color, mode); + await writeFileText(path.join(resolvedBundlesDir, fileName), content); + } + + const commonTemplate = await readTemplate('common'); + await writeFileText(path.join(resolvedBundlesDir, 'dx.common.scss'), commonTemplate); +} + +function loadDependencies(projectRoot: string): BuildDependencies { + const projectRequire = createRequire(path.join(projectRoot, 'package.json')); + const workspaceRequire = createRequire(path.join(projectRoot, '..', '..', 'package.json')); + + return { + sass: projectRequire('sass-embedded'), + postcss: workspaceRequire('postcss'), + autoprefixer: workspaceRequire('autoprefixer'), + CleanCss: workspaceRequire('clean-css'), + themeOptions: projectRequire(path.resolve(projectRoot, 'build/theme-options.cjs')) as { + getThemes: () => Array<[string, string, string, string?]>; + }, + cleanCssSanitizeOptions: projectRequire(path.resolve(projectRoot, 'build/clean-css-options.json')), + cleanCssDevOptions: workspaceRequire( + path.resolve(projectRoot, '../devextreme-themebuilder/src/data/clean-css-options.json'), + ), + devextremeVersion: workspaceRequire(path.resolve(projectRoot, '../devextreme/package.json')).version, + }; +} + +function resolveSourceFiles( + projectRoot: string, + options: ScssBuildExecutorSchema, +): Promise { + const bundlesDir = path.resolve(projectRoot, options.bundlesDir || DEFAULT_BUNDLES_DIR); + + if (options.mode === 'ci') { + const bundleNames = options.devBundles || DEFAULT_DEV_BUNDLE_NAMES; + return Promise.resolve(bundleNames.map((name) => path.join(bundlesDir, `dx.${name}.scss`))); + } + + return glob(path.join(bundlesDir, 'dx.*.scss'), { nodir: true }); +} + +function createDataUriFunction(projectRoot: string, sass: any): (args: any[]) => any { + return (args: any[]) => { + const argList = args[0].asList; + const hasEncoding = argList.size === 2; + const encoding = hasEncoding ? argList.get(0).assertString().text : undefined; + const url = argList.get(hasEncoding ? 1 : 0).assertString().text; + const absolutePath = path.resolve(projectRoot, url); + + const dataUri = resolveDataUri(absolutePath, encoding); + return new sass.SassString(`url("${dataUri}")`, { quotes: false }); + }; +} + +async function compileFile( + sourceFile: string, + outputDir: string, + options: ScssBuildExecutorSchema, + deps: BuildDependencies, + projectRoot: string, +): Promise { + const dataUriFunction = createDataUriFunction(projectRoot, deps.sass); + const compiled = deps.sass.compile(sourceFile, { + functions: { + 'data-uri($args...)': dataUriFunction, + }, + }); + + const postcssFactory = (deps.postcss as unknown as { default?: any }).default || deps.postcss; + const prefixed = await postcssFactory([deps.autoprefixer()]).process(compiled.css, { + from: undefined, + }); + + const minifierOptions = options.mode === 'ci' ? deps.cleanCssDevOptions : deps.cleanCssSanitizeOptions; + const minifier = new deps.CleanCss(minifierOptions); + const minified = minifier.minify(prefixed.css).styles; + + const outFileName = path.basename(sourceFile, '.scss') + '.css'; + const withHeader = createLicenseHeader(outFileName, deps.devextremeVersion) + moveCharsetToTop(minified); + await writeFileText(path.join(outputDir, outFileName), withHeader); } const runExecutor: PromiseExecutor = async (options, context) => { const projectRoot = resolveProjectPath(context); - const taskName = resolveTaskName(options); - const gulpBinary = options.gulpBinary || DEFAULT_GULP_BINARY; + const bundlesDir = options.bundlesDir || DEFAULT_BUNDLES_DIR; + const cssOutputDir = path.resolve(projectRoot, options.cssOutputDir || DEFAULT_CSS_OUTPUT_DIR); - logger.verbose(`Running SCSS build task "${taskName}" in mode "${options.mode}"`); + try { + const deps = loadDependencies(projectRoot); + await generateScssBundles(projectRoot, bundlesDir, deps); + await ensureDir(cssOutputDir); - const result = spawnSync('pnpm', ['exec', gulpBinary, taskName], { - cwd: projectRoot, - stdio: 'inherit', - shell: process.platform === 'win32', - env: process.env, - }); + const sources = await resolveSourceFiles(projectRoot, options); + const existingSources = sources.filter((source) => fs.existsSync(source)); - if (result.error) { - logger.error(`Failed to execute SCSS build task "${taskName}": ${result.error.message}`); - return { success: false }; - } + for (const source of existingSources) { + logger.verbose(`Compiling ${source}`); + await compileFile(source, cssOutputDir, options, deps, projectRoot); + } - if (result.status !== 0) { - logger.error(`SCSS build task "${taskName}" failed with exit code ${result.status ?? 1}`); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`SCSS build failed: ${message}`); return { success: false }; } - - return { success: true }; }; export default runExecutor; diff --git a/packages/nx-infra-plugin/src/executors/scss-build/schema.json b/packages/nx-infra-plugin/src/executors/scss-build/schema.json index ae326fc60aaa..168f3b7da931 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/schema.json +++ b/packages/nx-infra-plugin/src/executors/scss-build/schema.json @@ -9,20 +9,22 @@ "description": "Compilation mode. all = full themes set, ci = reduced dev themes set.", "enum": ["all", "ci"] }, - "gulpBinary": { + "bundlesDir": { "type": "string", - "description": "Gulp executable to run via pnpm exec", - "default": "gulp" + "description": "Generated SCSS bundles directory relative to project root", + "default": "./scss/bundles" }, - "allTaskName": { + "cssOutputDir": { "type": "string", - "description": "Gulp task name for full themes build", - "default": "style-compiler-themes" + "description": "Output CSS artifacts directory relative to project root", + "default": "../devextreme/artifacts/css" }, - "ciTaskName": { - "type": "string", - "description": "Gulp task name for CI/dev themes build", - "default": "style-compiler-themes-ci" + "devBundles": { + "type": "array", + "description": "Bundle names used in CI mode", + "items": { + "type": "string" + } } }, "required": ["mode"] diff --git a/packages/nx-infra-plugin/src/executors/scss-build/schema.ts b/packages/nx-infra-plugin/src/executors/scss-build/schema.ts index e58478f64be0..2df3f2853b66 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/schema.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/schema.ts @@ -1,6 +1,6 @@ export interface ScssBuildExecutorSchema { mode: 'all' | 'ci'; - gulpBinary?: string; - allTaskName?: string; - ciTaskName?: string; + bundlesDir?: string; + cssOutputDir?: string; + devBundles?: string[]; } From da84659b3aea8149372d5d63dcc6dd6c42bc4590 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 29 Apr 2026 23:36:36 +0200 Subject: [PATCH 4/9] move watching mode from gulp to nx-infra-plugin executor, --- packages/devextreme-scss/package.json | 4 +- packages/devextreme-scss/project.json | 21 ++- .../src/executors/scss-build/executor.ts | 174 ++++++++++++++++-- .../src/executors/scss-build/schema.json | 17 ++ .../src/executors/scss-build/schema.ts | 2 + 5 files changed, 201 insertions(+), 17 deletions(-) diff --git a/packages/devextreme-scss/package.json b/packages/devextreme-scss/package.json index 6d80381a2f0b..ec0f925e8d79 100644 --- a/packages/devextreme-scss/package.json +++ b/packages/devextreme-scss/package.json @@ -20,10 +20,10 @@ "ts-jest": "29.1.2" }, "scripts": { - "build": "gulp", + "build": "pnpm --workspace-root nx build devextreme-scss", "lint": "stylelint scss/widgets", "test": "jest --no-coverage --runInBand --config=./tests/jest.config.json", - "watch": "gulp watch" + "watch": "pnpm --workspace-root nx run devextreme-scss:watch" }, "version": "26.1.0" } diff --git a/packages/devextreme-scss/project.json b/packages/devextreme-scss/project.json index a52889b588eb..2fc6380391ea 100644 --- a/packages/devextreme-scss/project.json +++ b/packages/devextreme-scss/project.json @@ -53,8 +53,7 @@ "inputs": [ "{projectRoot}/build/**/*", "{projectRoot}/images/**/*", - "{projectRoot}/scss/**/*", - "{projectRoot}/gulpfile.js" + "{projectRoot}/scss/**/*" ], "outputs": [ "{projectRoot}/scss/bundles", @@ -70,8 +69,7 @@ "inputs": [ "{projectRoot}/build/**/*", "{projectRoot}/images/**/*", - "{projectRoot}/scss/**/*", - "{projectRoot}/gulpfile.js" + "{projectRoot}/scss/**/*" ], "outputs": [ "{projectRoot}/scss/bundles", @@ -131,6 +129,21 @@ ], "cache": true }, + "watch": { + "executor": "devextreme-nx-infra-plugin:scss-build", + "options": { + "mode": "all", + "watch": true + }, + "inputs": [ + "{projectRoot}/build/**/*", + "{projectRoot}/fonts/**/*", + "{projectRoot}/icons/**/*", + "{projectRoot}/images/**/*", + "{projectRoot}/scss/**/*" + ], + "cache": false + }, "lint": { "executor": "nx:run-script", "options": { diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts index 593a550e8196..e55b2db55256 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -37,6 +37,8 @@ interface BuildDependencies { devextremeVersion: string; } +type MinifyProfile = 'all' | 'ci'; + function resolveDataUri(filePath: string, svgEncoding?: string): string { const ext = path.extname(filePath).replace('.', ''); const data = fs.readFileSync(filePath); @@ -127,6 +129,21 @@ function loadDependencies(projectRoot: string): BuildDependencies { }; } +function normalizeBundlesOption(bundles?: string[] | string): string[] | undefined { + if (!bundles) { + return undefined; + } + + if (Array.isArray(bundles)) { + return bundles; + } + + return bundles + .split(',') + .map((bundle) => bundle.trim()) + .filter(Boolean); +} + function resolveSourceFiles( projectRoot: string, options: ScssBuildExecutorSchema, @@ -157,7 +174,7 @@ function createDataUriFunction(projectRoot: string, sass: any): (args: any[]) => async function compileFile( sourceFile: string, outputDir: string, - options: ScssBuildExecutorSchema, + minifyProfile: MinifyProfile, deps: BuildDependencies, projectRoot: string, ): Promise { @@ -173,7 +190,7 @@ async function compileFile( from: undefined, }); - const minifierOptions = options.mode === 'ci' ? deps.cleanCssDevOptions : deps.cleanCssSanitizeOptions; + const minifierOptions = minifyProfile === 'ci' ? deps.cleanCssDevOptions : deps.cleanCssSanitizeOptions; const minifier = new deps.CleanCss(minifierOptions); const minified = minifier.minify(prefixed.css).styles; @@ -182,24 +199,159 @@ async function compileFile( await writeFileText(path.join(outputDir, outFileName), withHeader); } -const runExecutor: PromiseExecutor = async (options, context) => { - const projectRoot = resolveProjectPath(context); +async function copyAssets(projectRoot: string, cssOutputDir: string): Promise { + const fontsFrom = path.resolve(projectRoot, 'fonts'); + const iconsFrom = path.resolve(projectRoot, 'icons'); + const fontsTo = path.resolve(cssOutputDir, 'fonts'); + const iconsTo = path.resolve(cssOutputDir, 'icons'); + + if (fs.existsSync(fontsFrom)) { + await ensureDir(fontsTo); + fs.cpSync(fontsFrom, fontsTo, { recursive: true }); + } + + if (fs.existsSync(iconsFrom)) { + await ensureDir(iconsTo); + fs.cpSync(iconsFrom, iconsTo, { recursive: true }); + } +} + +function resolveSourcesByBundleNames( + projectRoot: string, + bundlesDir: string, + bundleNames: string[], +): string[] { + const resolvedBundlesDir = path.resolve(projectRoot, bundlesDir); + const sources: string[] = []; + + for (const bundleName of bundleNames) { + const source = path.join(resolvedBundlesDir, `dx.${bundleName}.scss`); + if (fs.existsSync(source)) { + sources.push(source); + } else { + logger.warn(`${source} file does not exist`); + } + } + + return sources; +} + +function getWatchBundleNames(options: ScssBuildExecutorSchema): string[] { + const explicitBundles = normalizeBundlesOption(options.bundles); + if (explicitBundles && explicitBundles.length > 0) { + return explicitBundles; + } + + return options.devBundles || DEFAULT_DEV_BUNDLE_NAMES; +} + +async function runSingleBuild( + projectRoot: string, + options: ScssBuildExecutorSchema, + deps: BuildDependencies, +): Promise { const bundlesDir = options.bundlesDir || DEFAULT_BUNDLES_DIR; const cssOutputDir = path.resolve(projectRoot, options.cssOutputDir || DEFAULT_CSS_OUTPUT_DIR); - try { - const deps = loadDependencies(projectRoot); + await generateScssBundles(projectRoot, bundlesDir, deps); + await ensureDir(cssOutputDir); + + const sources = await resolveSourceFiles(projectRoot, options); + const existingSources = sources.filter((source) => fs.existsSync(source)); + const minifyProfile: MinifyProfile = options.mode === 'ci' ? 'ci' : 'all'; + + for (const source of existingSources) { + logger.verbose(`Compiling ${source}`); + await compileFile(source, cssOutputDir, minifyProfile, deps, projectRoot); + } +} + +async function runWatchBuild( + projectRoot: string, + options: ScssBuildExecutorSchema, + deps: BuildDependencies, +): Promise<{ success: boolean }> { + const bundlesDir = options.bundlesDir || DEFAULT_BUNDLES_DIR; + const cssOutputDir = path.resolve(projectRoot, options.cssOutputDir || DEFAULT_CSS_OUTPUT_DIR); + const watchDir = path.resolve(projectRoot, 'scss'); + const watchBundleNames = getWatchBundleNames(options); + + const rebuild = async (): Promise => { await generateScssBundles(projectRoot, bundlesDir, deps); await ensureDir(cssOutputDir); - const sources = await resolveSourceFiles(projectRoot, options); - const existingSources = sources.filter((source) => fs.existsSync(source)); + const sources = resolveSourcesByBundleNames(projectRoot, bundlesDir, watchBundleNames); + for (const source of sources) { + await compileFile(source, cssOutputDir, 'all', deps, projectRoot); + } + + await copyAssets(projectRoot, cssOutputDir); + }; + + await rebuild(); + logger.info('scss-build watch mode is watching for changes...'); + + return await new Promise<{ success: boolean }>((resolve) => { + let timer: NodeJS.Timeout | undefined; + let busy = false; + + const scheduleRebuild = () => { + if (timer) { + clearTimeout(timer); + } + + timer = setTimeout(async () => { + if (busy) { + return; + } + + busy = true; + try { + await rebuild(); + logger.info('scss-build watch: rebuild complete'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`scss-build watch rebuild failed: ${message}`); + } finally { + busy = false; + } + }, 200); + }; + + const watcher = fs.watch( + watchDir, + { recursive: true }, + (_eventType, fileName) => { + if (!fileName || !fileName.endsWith('.scss')) { + return; + } + scheduleRebuild(); + }, + ); + + const stopWatcher = () => { + watcher.close(); + if (timer) { + clearTimeout(timer); + } + resolve({ success: true }); + }; - for (const source of existingSources) { - logger.verbose(`Compiling ${source}`); - await compileFile(source, cssOutputDir, options, deps, projectRoot); + process.once('SIGINT', stopWatcher); + process.once('SIGTERM', stopWatcher); + }); +} + +const runExecutor: PromiseExecutor = async (options, context) => { + const projectRoot = resolveProjectPath(context); + + try { + const deps = loadDependencies(projectRoot); + if (options.watch) { + return await runWatchBuild(projectRoot, options, deps); } + await runSingleBuild(projectRoot, options, deps); return { success: true }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/nx-infra-plugin/src/executors/scss-build/schema.json b/packages/nx-infra-plugin/src/executors/scss-build/schema.json index 168f3b7da931..46150b76b51a 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/schema.json +++ b/packages/nx-infra-plugin/src/executors/scss-build/schema.json @@ -25,6 +25,23 @@ "items": { "type": "string" } + }, + "watch": { + "type": "boolean", + "description": "Watch SCSS sources and rebuild on changes", + "default": false + }, + "bundles": { + "description": "Bundle names for watch mode (array or comma-separated string)", + "oneOf": [ + { + "type": "array", + "items": { "type": "string" } + }, + { + "type": "string" + } + ] } }, "required": ["mode"] diff --git a/packages/nx-infra-plugin/src/executors/scss-build/schema.ts b/packages/nx-infra-plugin/src/executors/scss-build/schema.ts index 2df3f2853b66..cbbcb5dccd3d 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/schema.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/schema.ts @@ -3,4 +3,6 @@ export interface ScssBuildExecutorSchema { bundlesDir?: string; cssOutputDir?: string; devBundles?: string[]; + watch?: boolean; + bundles?: string[] | string; } From a8321c99c4c3ebe0b8de2a02da98148dee416082 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Thu, 30 Apr 2026 00:06:25 +0200 Subject: [PATCH 5/9] remove gulp from devextreme-scss --- .github/workflows/themebuilder_tests.yml | 2 +- .../devextreme-scss/build/gulp-data-uri.js | 46 ----- .../devextreme-scss/build/style-compiler.js | 164 ------------------ packages/devextreme-scss/gulpfile.js | 40 ----- packages/devextreme-scss/package.json | 10 -- packages/devextreme-scss/project.json | 6 +- packages/devextreme/package.json | 2 +- pnpm-lock.yaml | 31 +--- 8 files changed, 5 insertions(+), 296 deletions(-) delete mode 100644 packages/devextreme-scss/build/gulp-data-uri.js delete mode 100644 packages/devextreme-scss/build/style-compiler.js delete mode 100644 packages/devextreme-scss/gulpfile.js diff --git a/.github/workflows/themebuilder_tests.yml b/.github/workflows/themebuilder_tests.yml index da7ccceed35d..d5180e339cd1 100644 --- a/.github/workflows/themebuilder_tests.yml +++ b/.github/workflows/themebuilder_tests.yml @@ -52,7 +52,7 @@ jobs: - name: Build etalon bundles working-directory: ./packages/devextreme-scss - run: pnpm exec gulp style-compiler-themes-ci + run: pnpm --workspace-root nx run devextreme-scss:build:ci - name: Build working-directory: ./packages/devextreme-themebuilder diff --git a/packages/devextreme-scss/build/gulp-data-uri.js b/packages/devextreme-scss/build/gulp-data-uri.js deleted file mode 100644 index 699d1a4d51c2..000000000000 --- a/packages/devextreme-scss/build/gulp-data-uri.js +++ /dev/null @@ -1,46 +0,0 @@ -import path, { dirname } from 'path'; -import fs from 'fs'; -import sass from 'sass-embedded'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const dataUriRegex = /data-uri\((?:'(image\/svg\+xml;charset=UTF-8)',\s)?['"]?([^)'"]+)['"]?\)/g; - -const svg = (buffer, svgEncoding) => { - const encoding = svgEncoding || 'image/svg+xml;charset=UTF-8'; - const svg = encodeURIComponent(buffer.toString()); - - return `"data:${encoding},${svg}"`; -}; - -const img = (buffer, ext) => { - return `"data:image/${ext};base64,${buffer.toString('base64')}"`; -}; - -const handler = (_, svgEncoding, fileName) => { - const relativePath = path.join(__dirname, '..', fileName); - const filePath = path.resolve(relativePath); - const ext = filePath.split('.').pop(); - const data = fs.readFileSync(filePath); - const buffer = Buffer.from(data); - const escapedString = ext === 'svg' ? svg(buffer, svgEncoding) : img(buffer, ext); - return `url(${escapedString})`; -}; - -const sassFunction = (args) => { - const getTextFromSass = (sassValue) => sassValue.assertString().text; - const argList = args[0].asList; - const hasEncoding = argList.size === 2; - const encoding = hasEncoding ? getTextFromSass(argList.get(0)) : null; - const url = getTextFromSass(argList.get(hasEncoding ? 1 : 0)); - - return new sass.SassString(handler(null, encoding, url), { quotes: false }); -}; - -export const resolveDataUri = (content) => content.replace(dataUriRegex, handler); - -export const sassFunctions = { - 'data-uri($args...)': sassFunction, -}; diff --git a/packages/devextreme-scss/build/style-compiler.js b/packages/devextreme-scss/build/style-compiler.js deleted file mode 100644 index 29f70deab20f..000000000000 --- a/packages/devextreme-scss/build/style-compiler.js +++ /dev/null @@ -1,164 +0,0 @@ -import gulp from 'gulp'; -const { task, src, parallel, series, dest, watch } = gulp; - -import { join } from 'path'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import replace from 'gulp-replace'; -import plumber from 'gulp-plumber'; -import gulpSass from 'gulp-sass'; -import sassEmbedded from 'sass-embedded'; -import CleanCss from 'clean-css'; -import through from 'through2'; -import parseArguments from 'minimist'; -import autoprefixer from 'gulp-autoprefixer'; -import { createRequire } from 'module'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const require = createRequire(import.meta.url); -const cleanCssSanitizeOptions = require('./clean-css-options.json'); -const cleanCssOptions = require('../../devextreme-themebuilder/src/data/clean-css-options.json'); -const { starLicense } = require('../../devextreme/build/gulp/header-pipes.js'); - -const { getThemes } = require('./theme-options.cjs'); -import { sassFunctions } from './gulp-data-uri.js'; - -const sass = gulpSass(sassEmbedded); - -const cssArtifactsPath = join(process.cwd(), '..', 'devextreme', 'artifacts', 'css'); - -const DEFAULT_DEV_BUNDLE_NAMES = [ - 'light', - 'light.compact', - 'dark', - 'contrast', - 'material.blue.light', - 'material.blue.light.compact', - 'material.blue.dark', - 'fluent.blue.light', - 'fluent.blue.light.compact', - 'fluent.blue.dark', - 'fluent.saas.light', - 'fluent.saas.dark', -]; - -const getBundleSourcePath = name => `scss/bundles/dx.${name}.scss`; - -const compileBundles = (bundles, isDevBundle) => { - return src(bundles) - .pipe(plumber(e => { - console.log(e); - this.emit('end'); - })) - .on('data', (chunk) => console.log('Build: ', chunk.path)) - .pipe(sass({ - functions: sassFunctions - })) - .pipe(autoprefixer()) - .pipe(through.obj((file, enc, callback) => { - const content = file.contents.toString(); - new CleanCss(isDevBundle ? cleanCssOptions : cleanCssSanitizeOptions).minify(content, (_, css) => { - file.contents = new Buffer.from(css.styles); - callback(null, file); - }); - })) - .pipe(starLicense()) - .pipe(replace(/([\s\S]*)(@charset.*?;\s)/, '$2$1')) - .pipe(dest(cssArtifactsPath)); -}; - -function saveBundleFile(folder, fileName, content) { - const bundlePath = join(folder, fileName); - if(!existsSync(folder)) mkdirSync(folder); - writeFileSync(bundlePath, content); -} - -function generateScssBundleName(theme, size, color, mode) { - return 'dx' + - (theme === 'material' || theme === 'fluent' - ? `.${theme}` - : '') - + `.${color}` + - (mode ? `.${mode}` : '') + - (size === 'default' ? '' : '.compact') + - '.scss'; -} - -function generateScssBundles(bundlesFolder, getBundleContent) { - const saveBundle = (theme, size, color, mode) => { - const bundleName = generateScssBundleName(theme, size, color, mode); - const content = getBundleContent(theme, size, color, mode); - - saveBundleFile(bundlesFolder, bundleName, content); - }; - - getThemes().forEach(([theme, size, color, mode]) => saveBundle(theme, size, color, mode)); -} - -function createBundles(callback) { - const bundlesFolder = join(process.cwd(), 'scss', 'bundles'); - const readTemplate = (theme) => readFileSync(join(__dirname, `bundle-template.${theme}.scss`), 'utf8'); - const getBundleContent = (theme, size, color, mode) => { - const bundleTemplate = readTemplate(theme); - const bundleContent = bundleTemplate - .replace('$COLOR', color) - .replace('$SIZE', size) - .replace('$MODE', mode); - return bundleContent; - }; - - generateScssBundles(bundlesFolder, getBundleContent); - saveBundleFile(bundlesFolder, 'dx.common.scss', readTemplate('common')); - - if(callback) callback(); -} - -task('create-scss-bundles', createBundles); - -task('copy-fonts-and-icons', () => { - return src(['fonts/**/*', 'icons/**/*'], { base: '.' }) - .pipe(dest(cssArtifactsPath)); -}); - -task('compile-themes-all', () => compileBundles(getBundleSourcePath('*'))); -task('compile-themes-dev', () => compileBundles(DEFAULT_DEV_BUNDLE_NAMES.map(getBundleSourcePath), true)); - -task('style-compiler-themes', series( - 'create-scss-bundles', - parallel( - 'compile-themes-all', - 'copy-fonts-and-icons' - ) -)); - -task('style-compiler-themes-ci', series( - 'create-scss-bundles', - parallel( - 'compile-themes-dev', - 'copy-fonts-and-icons' - ) -)); - -task('style-compiler-themes-watch', () => { - const args = parseArguments(process.argv); - const bundlesArg = args['bundles']; - - const bundles = ( - bundlesArg - ? bundlesArg.split(',') - : DEFAULT_DEV_BUNDLE_NAMES) - .map((bundle) => { - const sourcePath = getBundleSourcePath(bundle); - if(existsSync(sourcePath)) { - return sourcePath; - } - console.log(`${sourcePath} file does not exists`); - return null; - }); - - watch('scss/**/*', parallel(() => compileBundles(bundles), 'copy-fonts-and-icons')) - .on('ready', () => console.log('style-compiler-themes task is watching for changes...')); -}); diff --git a/packages/devextreme-scss/gulpfile.js b/packages/devextreme-scss/gulpfile.js deleted file mode 100644 index 0494cad08db7..000000000000 --- a/packages/devextreme-scss/gulpfile.js +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-env node */ -/* eslint-disable no-console */ - -import gulp from 'gulp'; -import cache from 'gulp-cache'; -import { createRequire } from 'module'; - -const require = createRequire(import.meta.url); -const env = require('../devextreme/build/gulp/env-variables.js'); -const del = require('del'); - -gulp.task('clean', function(callback) { - del.sync([ - '../devextreme/artifacts/css/**', - '../devextreme/scss/bundles/**' - ], { force: true }); - cache.clearAll(); - callback(); -}); - -import './build/style-compiler.js'; - -if(env.TEST_CI) { - console.warn('Using test CI mode!'); -} - -function createStyleCompilerBatch() { - return gulp.series( - 'clean', - env.TEST_CI - ? ['style-compiler-themes-ci'] - : ['style-compiler-themes'] - ); -} - -gulp.task('default', createStyleCompilerBatch()); - -gulp.task('watch', gulp.series( - 'style-compiler-themes-watch' -)); diff --git a/packages/devextreme-scss/package.json b/packages/devextreme-scss/package.json index ec0f925e8d79..db8a35022966 100644 --- a/packages/devextreme-scss/package.json +++ b/packages/devextreme-scss/package.json @@ -3,20 +3,10 @@ "type": "module", "devDependencies": { "clean-css": "5.3.3", - "del": "2.2.2", - "gulp": "4.0.2", - "gulp-autoprefixer": "10.0.0", - "gulp-cache": "1.1.3", - "gulp-plumber": "1.2.1", - "gulp-replace": "0.6.1", - "gulp-sass": "6.0.1", - "gulp-shell": "0.8.0", - "minimist": "1.2.8", "sass-embedded": "1.93.3", "stylelint": "15.11.0", "stylelint-config-standard-scss": "9.0.0", "stylelint-scss": "6.10.0", - "through2": "2.0.5", "ts-jest": "29.1.2" }, "scripts": { diff --git a/packages/devextreme-scss/project.json b/packages/devextreme-scss/project.json index 2fc6380391ea..f720e5dfbfdd 100644 --- a/packages/devextreme-scss/project.json +++ b/packages/devextreme-scss/project.json @@ -92,8 +92,7 @@ "{projectRoot}/fonts/**/*", "{projectRoot}/icons/**/*", "{projectRoot}/images/**/*", - "{projectRoot}/scss/**/*", - "{projectRoot}/gulpfile.js" + "{projectRoot}/scss/**/*" ], "outputs": [ "{projectRoot}/scss/bundles", @@ -118,8 +117,7 @@ "{projectRoot}/fonts/**/*", "{projectRoot}/icons/**/*", "{projectRoot}/images/**/*", - "{projectRoot}/scss/**/*", - "{projectRoot}/gulpfile.js" + "{projectRoot}/scss/**/*" ], "outputs": [ "{projectRoot}/scss/bundles", diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 71d919974a68..5f26c43f09d0 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -233,7 +233,7 @@ "build:testcafe": "cross-env DEVEXTREME_TEST_CI=TRUE BUILD_ESM_PACKAGE=true BUILD_TESTCAFE=TRUE gulp default", "build-npm-devextreme": "cross-env BUILD_ESM_PACKAGE=true gulp default", "build-dist": "cross-env BUILD_ESM_PACKAGE=true gulp default --uglify", - "build-themes": "gulp style-compiler-themes", + "build-themes": "pnpm --workspace-root nx run devextreme-scss:build:themes && pnpm --workspace-root nx run devextreme-scss:copy:assets", "build:react": "gulp generate-react", "build:react:watch": "gulp generate-react-watch", "build:react:typescript": "gulp generate-react-typescript", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 345327887735..e0debef85932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2105,33 +2105,6 @@ importers: clean-css: specifier: 5.3.3 version: 5.3.3 - del: - specifier: 2.2.2 - version: 2.2.2 - gulp: - specifier: 4.0.2 - version: 4.0.2 - gulp-autoprefixer: - specifier: 10.0.0 - version: 10.0.0(gulp@4.0.2) - gulp-cache: - specifier: 1.1.3 - version: 1.1.3 - gulp-plumber: - specifier: 1.2.1 - version: 1.2.1 - gulp-replace: - specifier: 0.6.1 - version: 0.6.1 - gulp-sass: - specifier: 6.0.1 - version: 6.0.1 - gulp-shell: - specifier: 0.8.0 - version: 0.8.0 - minimist: - specifier: 1.2.8 - version: 1.2.8 sass-embedded: specifier: 1.93.3 version: 1.93.3 @@ -2144,9 +2117,6 @@ importers: stylelint-scss: specifier: 6.10.0 version: 6.10.0(stylelint@15.11.0(typescript@5.9.3)) - through2: - specifier: 2.0.5 - version: 2.0.5 ts-jest: specifier: 29.1.2 version: 29.1.2(@babel/core@7.29.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest@30.2.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0)(node-notifier@9.0.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) @@ -15037,6 +15007,7 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qified@0.9.1: From 22d0a62d58a96160f181a559e6825bbd7c26bc99 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Thu, 30 Apr 2026 00:26:08 +0200 Subject: [PATCH 6/9] add tests --- .../executors/scss-build/executor.e2e.spec.ts | 181 +++++++++++++++++- .../src/executors/scss-build/executor.ts | 5 +- 2 files changed, 182 insertions(+), 4 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts index 9a21bbb3834e..5c1eed640695 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts @@ -1,5 +1,182 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import executor from './executor'; +import { ScssBuildExecutorSchema } from './schema'; +import { createMockContext, createTempDir, cleanupTempDir } from '../../utils/test-utils'; +import { writeFileText, writeJson, readFileText } from '../../utils'; + +function createMockModules(workspaceRoot: string, projectRoot: string): void { + const projectNodeModules = path.join(projectRoot, 'node_modules', 'sass-embedded'); + fs.mkdirSync(projectNodeModules, { recursive: true }); + fs.writeFileSync( + path.join(projectNodeModules, 'index.js'), + [ + 'class SassString {', + ' constructor(value) { this.value = value; }', + '}', + 'module.exports = {', + ' SassString,', + " compile: () => ({ css: '@charset \"UTF-8\"; .a{display:flex}' })", + '};', + '', + ].join('\n'), + 'utf8', + ); + + const workspaceNodeModules = path.join(workspaceRoot, 'node_modules'); + fs.mkdirSync(workspaceNodeModules, { recursive: true }); + + const postcssDir = path.join(workspaceNodeModules, 'postcss'); + fs.mkdirSync(postcssDir, { recursive: true }); + fs.writeFileSync( + path.join(postcssDir, 'index.js'), + [ + 'module.exports = function postcss() {', + ' return {', + ' process: async (css) => ({ css: css + "/*prefixed*/" })', + ' };', + '};', + '', + ].join('\n'), + 'utf8', + ); + + const autoprefixerDir = path.join(workspaceNodeModules, 'autoprefixer'); + fs.mkdirSync(autoprefixerDir, { recursive: true }); + fs.writeFileSync( + path.join(autoprefixerDir, 'index.js'), + 'module.exports = function autoprefixer() { return { postcssPlugin: "autoprefixer" }; };', + 'utf8', + ); + + const cleanCssDir = path.join(workspaceNodeModules, 'clean-css'); + fs.mkdirSync(cleanCssDir, { recursive: true }); + fs.writeFileSync( + path.join(cleanCssDir, 'index.js'), + [ + 'module.exports = class CleanCss {', + ' constructor(options) { this.options = options || {}; }', + ' minify(css) {', + ' return { styles: css + "/*min:" + (this.options.profile || "none") + "*/" };', + ' }', + '};', + '', + ].join('\n'), + 'utf8', + ); +} + +async function setupProjectStructure(workspaceRoot: string): Promise { + const projectRoot = path.join(workspaceRoot, 'packages', 'devextreme-scss'); + const buildDir = path.join(projectRoot, 'build'); + fs.mkdirSync(buildDir, { recursive: true }); + + await writeJson(path.join(workspaceRoot, 'package.json'), { name: 'workspace' }); + await writeJson(path.join(projectRoot, 'package.json'), { name: 'devextreme-scss' }); + + await writeJson(path.join(projectRoot, 'build', 'clean-css-options.json'), { profile: 'all' }); + + const themebuilderDataDir = path.join( + workspaceRoot, + 'packages', + 'devextreme-themebuilder', + 'src', + 'data', + ); + fs.mkdirSync(themebuilderDataDir, { recursive: true }); + await writeJson(path.join(themebuilderDataDir, 'clean-css-options.json'), { profile: 'ci' }); + + const devextremeDir = path.join(workspaceRoot, 'packages', 'devextreme'); + fs.mkdirSync(devextremeDir, { recursive: true }); + await writeJson(path.join(devextremeDir, 'package.json'), { version: '26.1.0-test' }); + + await writeFileText( + path.join(buildDir, 'theme-options.cjs'), + [ + 'module.exports = {', + ' getThemes: () => [', + " ['generic', 'default', 'light'],", + ' ],', + '};', + '', + ].join('\n'), + ); + + await writeFileText(path.join(buildDir, 'bundle-template.common.scss'), '.common { color: red; }'); + await writeFileText(path.join(buildDir, 'bundle-template.generic.scss'), '.generic-$COLOR { color: red; }'); + + createMockModules(workspaceRoot, projectRoot); + return projectRoot; +} + describe('ScssBuildExecutor E2E', () => { - it('has test placeholder for native pipeline', () => { - expect(true).toBe(true); + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir('nx-scss-build-e2e-'); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('builds all mode bundles and applies license/minification profile', async () => { + const projectRoot = await setupProjectStructure(tempDir); + const context = createMockContext({ + root: tempDir, + projectName: 'devextreme-scss', + projectRoot: 'packages/devextreme-scss', + }); + + const options: ScssBuildExecutorSchema = { mode: 'all', cssOutputDir: './artifacts/css' }; + const result = await executor(options, context); + + expect(result.success).toBe(true); + expect(fs.existsSync(path.join(projectRoot, 'scss', 'bundles', 'dx.light.scss'))).toBe(true); + expect(fs.existsSync(path.join(projectRoot, 'scss', 'bundles', 'dx.common.scss'))).toBe(true); + + const cssDir = path.join(projectRoot, 'artifacts', 'css'); + const generatedCssFiles = fs + .readdirSync(cssDir) + .filter((name) => name.endsWith('.css')) + .sort(); + expect(generatedCssFiles.length).toBeGreaterThan(0); + expect(generatedCssFiles).toContain('dx.common.css'); + + const commonCss = await readFileText(path.join(cssDir, 'dx.common.css')); + + expect(commonCss).toContain('Version: 26.1.0-test'); + expect(commonCss).toContain('/*min:all*/'); + expect(commonCss).toContain('DevExtreme (dx.common.css)'); + }); + + it('builds ci mode only for selected dev bundles and uses ci profile', async () => { + const projectRoot = await setupProjectStructure(tempDir); + const context = createMockContext({ + root: tempDir, + projectName: 'devextreme-scss', + projectRoot: 'packages/devextreme-scss', + }); + + const options: ScssBuildExecutorSchema = { + mode: 'ci', + devBundles: ['light'], + cssOutputDir: './artifacts/css', + }; + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const cssDir = path.join(projectRoot, 'artifacts', 'css'); + const generatedCssFiles = fs + .readdirSync(cssDir) + .filter((name) => name.endsWith('.css')) + .sort(); + + expect(generatedCssFiles).toEqual(['dx.light.css']); + const lightCss = await readFileText(path.join(cssDir, 'dx.light.css')); + expect(lightCss).toContain('/*min:ci*/'); + + expect(fs.existsSync(path.join(projectRoot, 'scss', 'bundles', 'dx.common.scss'))).toBe(true); }); }); diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts index e55b2db55256..f1be4ee994c7 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import { createRequire } from 'module'; import { glob } from 'glob'; import { ScssBuildExecutorSchema } from './schema'; -import { resolveProjectPath } from '../../utils/path-resolver'; +import { normalizeGlobPathForWindows, resolveProjectPath } from '../../utils/path-resolver'; import { ensureDir, readFileText, writeFileText } from '../../utils/file-operations'; const DEFAULT_BUNDLES_DIR = './scss/bundles'; @@ -155,7 +155,8 @@ function resolveSourceFiles( return Promise.resolve(bundleNames.map((name) => path.join(bundlesDir, `dx.${name}.scss`))); } - return glob(path.join(bundlesDir, 'dx.*.scss'), { nodir: true }); + const pattern = normalizeGlobPathForWindows(path.join(bundlesDir, 'dx.*.scss')); + return glob(pattern, { nodir: true }); } function createDataUriFunction(projectRoot: string, sass: any): (args: any[]) => any { From 026df10aca6a4cf7b4b9b3d2dbbd4b8468e74cf5 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Thu, 30 Apr 2026 13:35:23 +0200 Subject: [PATCH 7/9] fix executor for dex-scss --- .../src/modules/compile-manager.ts | 2 +- .../src/modules/post-compiler.ts | 30 ++++++-- .../executors/scss-build/executor.e2e.spec.ts | 12 +++- .../src/executors/scss-build/executor.ts | 68 +++++++++++-------- 4 files changed, 73 insertions(+), 39 deletions(-) diff --git a/packages/devextreme-themebuilder/src/modules/compile-manager.ts b/packages/devextreme-themebuilder/src/modules/compile-manager.ts index d7a112a26035..d98d4127d7c0 100644 --- a/packages/devextreme-themebuilder/src/modules/compile-manager.ts +++ b/packages/devextreme-themebuilder/src/modules/compile-manager.ts @@ -69,7 +69,7 @@ export default class CompileManager { css = removeExternalResources(css); } - css = addInfoHeader(css, version); + css = addInfoHeader(css, version, true); return { compiledMetadata: compileData.changedVariables, diff --git a/packages/devextreme-themebuilder/src/modules/post-compiler.ts b/packages/devextreme-themebuilder/src/modules/post-compiler.ts index 30ce6798a514..0ecc5a5f01fd 100644 --- a/packages/devextreme-themebuilder/src/modules/post-compiler.ts +++ b/packages/devextreme-themebuilder/src/modules/post-compiler.ts @@ -10,19 +10,39 @@ export function addBasePath(css: string | Buffer, basePath: string): string { return css.toString().replace(/(url\()("|')?(icons|fonts)/g, `$1$2${normalizedPath}$3`); } -export function addInfoHeader(css: string | Buffer, version: string): string { +function buildThemeBuilderInfoHeader(version: string): string { const generatedBy = '* Generated by the DevExpress ThemeBuilder'; const versionString = `* Version: ${version}`; const link = '* http://js.devexpress.com/ThemeBuilder/'; - const header = `/*${generatedBy}\n${versionString}\n${link}\n*/\n\n`; + return `/*${generatedBy}\n${versionString}\n${link}\n*/\n\n`; +} + +export function addInfoHeader( + css: string | Buffer, + version: string, + appendInfoHeaderAfterBody = false, +): string { + const header = buildThemeBuilderInfoHeader(version); const source = css.toString(); const encoding = '@charset "UTF-8";'; - if (source.startsWith(encoding)) { - return `${encoding}\n${header}${source.replace(`${encoding}\n`, '')}`; + // clean-css may emit @charset immediately followed by :root / @import with no newline. + const charsetPrefix = /^@charset\s+"utf-8";\s*/i; + const match = source.match(charsetPrefix); + if (match) { + const rest = source.slice(match[0].length).trimStart(); + + if (appendInfoHeaderAfterBody) { + const joined = `${encoding.trimEnd()}${rest}`.replace( + /^(@charset\s+"utf-8";)\s+/i, + '$1', + ); + return `${joined}\n${header}`; + } + return `${encoding}\n${header}${rest}`; } - return `${header}${css}`; + return `${header}${source}`; } export async function cleanCss(css: string): Promise { diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts index 5c1eed640695..c7c8c46bd971 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts @@ -16,7 +16,7 @@ function createMockModules(workspaceRoot: string, projectRoot: string): void { '}', 'module.exports = {', ' SassString,', - " compile: () => ({ css: '@charset \"UTF-8\"; .a{display:flex}' })", + ' compile: () => ({ css: \'@charset "UTF-8"; .a{display:flex}\' })', '};', '', ].join('\n'), @@ -102,8 +102,14 @@ async function setupProjectStructure(workspaceRoot: string): Promise { ].join('\n'), ); - await writeFileText(path.join(buildDir, 'bundle-template.common.scss'), '.common { color: red; }'); - await writeFileText(path.join(buildDir, 'bundle-template.generic.scss'), '.generic-$COLOR { color: red; }'); + await writeFileText( + path.join(buildDir, 'bundle-template.common.scss'), + '.common { color: red; }', + ); + await writeFileText( + path.join(buildDir, 'bundle-template.generic.scss'), + '.generic-$COLOR { color: red; }', + ); createMockModules(workspaceRoot, projectRoot); return projectRoot; diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts index f1be4ee994c7..75fdeeee4d9d 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -29,7 +29,7 @@ const EULA_URL = 'https://js.devexpress.com/Licensing/'; interface BuildDependencies { sass: any; postcss: any; - autoprefixer: () => any; + autoprefixer: (options?: { overrideBrowserslist?: string[] }) => any; CleanCss: new (options: unknown) => { minify: (input: string) => { styles: string } }; themeOptions: { getThemes: () => Array<[string, string, string, string?]> }; cleanCssSanitizeOptions: unknown; @@ -51,9 +51,13 @@ function resolveDataUri(filePath: string, svgEncoding?: string): string { return `data:image/${ext};base64,${data.toString('base64')}`; } -function createLicenseHeader(fileName: string, version: string): string { +/** + * Same shape as `packages/devextreme/build/gulp/license-header.txt` with + * `gulp-header` `commentType: '*'` (starLicense) — matches legacy Gulp output. + */ +function createStarLicenseHeader(fileName: string, version: string): string { return [ - '/*!', + '/**', `* DevExtreme (${fileName.replace(/\\/g, '/')})`, `* Version: ${version}`, `* Build date: ${new Date().toDateString()}`, @@ -65,24 +69,24 @@ function createLicenseHeader(fileName: string, version: string): string { ].join('\n'); } -function moveCharsetToTop(css: string): string { - const match = css.match(/@charset\s+[^;]+;\s*/); - if (!match) { - return css; - } - - const charset = match[0]; - const withoutCharset = css.replace(charset, ''); - return charset + withoutCharset; +/** + * Mirrors `style-compiler.js`: starLicense prepend, then + * `.replace(/([\s\S]*)(@charset.*?;\s)/, '$2$1')` so `@charset` is the first bytes of output. + */ +function prependLicenseAndMoveCharsetFirst(minifiedCss: string, license: string): string { + const withLicense = `${license}${minifiedCss}`; + return withLicense.replace(/([\s\S]*)(@charset[^;]+;\s*)/, '$2$1'); } function generateBundleName(theme: string, size: string, color: string, mode?: string): string { - return 'dx' + return ( + 'dx' + (theme === 'material' || theme === 'fluent' ? `.${theme}` : '') + `.${color}` + (mode ? `.${mode}` : '') + (size === 'default' ? '' : '.compact') - + '.scss'; + + '.scss' + ); } async function generateScssBundles( @@ -100,7 +104,10 @@ async function generateScssBundles( const themes = deps.themeOptions.getThemes(); for (const [theme, size, color, mode] of themes) { const template = await readTemplate(theme); - const content = template.replace('$COLOR', color).replace('$SIZE', size).replace('$MODE', mode || ''); + const content = template + .replace('$COLOR', color) + .replace('$SIZE', size) + .replace('$MODE', mode || ''); const fileName = generateBundleName(theme, size, color, mode); await writeFileText(path.join(resolvedBundlesDir, fileName), content); } @@ -121,11 +128,14 @@ function loadDependencies(projectRoot: string): BuildDependencies { themeOptions: projectRequire(path.resolve(projectRoot, 'build/theme-options.cjs')) as { getThemes: () => Array<[string, string, string, string?]>; }, - cleanCssSanitizeOptions: projectRequire(path.resolve(projectRoot, 'build/clean-css-options.json')), + cleanCssSanitizeOptions: projectRequire( + path.resolve(projectRoot, 'build/clean-css-options.json'), + ), cleanCssDevOptions: workspaceRequire( path.resolve(projectRoot, '../devextreme-themebuilder/src/data/clean-css-options.json'), ), - devextremeVersion: workspaceRequire(path.resolve(projectRoot, '../devextreme/package.json')).version, + devextremeVersion: workspaceRequire(path.resolve(projectRoot, '../devextreme/package.json')) + .version, }; } @@ -188,15 +198,17 @@ async function compileFile( const postcssFactory = (deps.postcss as unknown as { default?: any }).default || deps.postcss; const prefixed = await postcssFactory([deps.autoprefixer()]).process(compiled.css, { - from: undefined, + from: sourceFile, }); - const minifierOptions = minifyProfile === 'ci' ? deps.cleanCssDevOptions : deps.cleanCssSanitizeOptions; + const minifierOptions = + minifyProfile === 'ci' ? deps.cleanCssDevOptions : deps.cleanCssSanitizeOptions; const minifier = new deps.CleanCss(minifierOptions); const minified = minifier.minify(prefixed.css).styles; const outFileName = path.basename(sourceFile, '.scss') + '.css'; - const withHeader = createLicenseHeader(outFileName, deps.devextremeVersion) + moveCharsetToTop(minified); + const license = createStarLicenseHeader(outFileName, deps.devextremeVersion); + const withHeader = prependLicenseAndMoveCharsetFirst(minified, license); await writeFileText(path.join(outputDir, outFileName), withHeader); } @@ -319,16 +331,12 @@ async function runWatchBuild( }, 200); }; - const watcher = fs.watch( - watchDir, - { recursive: true }, - (_eventType, fileName) => { - if (!fileName || !fileName.endsWith('.scss')) { - return; - } - scheduleRebuild(); - }, - ); + const watcher = fs.watch(watchDir, { recursive: true }, (_eventType, fileName) => { + if (!fileName || !fileName.endsWith('.scss')) { + return; + } + scheduleRebuild(); + }); const stopWatcher = () => { watcher.close(); From d203e38463ea2cb4c4ca595a983caea66c00487d Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Tue, 5 May 2026 23:51:45 +0200 Subject: [PATCH 8/9] fix review comments --- .github/workflows/themebuilder_tests.yml | 2 +- packages/devextreme-scss/package.json | 2 +- .../tests/modules/post-compiler.test.ts | 17 ++++++++++++++ packages/devextreme/package.json | 2 +- .../executors/scss-build/executor.e2e.spec.ts | 18 +++++++++++++++ .../src/executors/scss-build/executor.ts | 23 +++++++++++++------ 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/.github/workflows/themebuilder_tests.yml b/.github/workflows/themebuilder_tests.yml index d5180e339cd1..2b34ccd1d856 100644 --- a/.github/workflows/themebuilder_tests.yml +++ b/.github/workflows/themebuilder_tests.yml @@ -52,7 +52,7 @@ jobs: - name: Build etalon bundles working-directory: ./packages/devextreme-scss - run: pnpm --workspace-root nx run devextreme-scss:build:ci + run: pnpm --workspace-root nx build:ci devextreme-scss - name: Build working-directory: ./packages/devextreme-themebuilder diff --git a/packages/devextreme-scss/package.json b/packages/devextreme-scss/package.json index db8a35022966..5e0b4e5c071b 100644 --- a/packages/devextreme-scss/package.json +++ b/packages/devextreme-scss/package.json @@ -13,7 +13,7 @@ "build": "pnpm --workspace-root nx build devextreme-scss", "lint": "stylelint scss/widgets", "test": "jest --no-coverage --runInBand --config=./tests/jest.config.json", - "watch": "pnpm --workspace-root nx run devextreme-scss:watch" + "watch": "pnpm --workspace-root nx run devextreme-scss --target=watch" }, "version": "26.1.0" } diff --git a/packages/devextreme-themebuilder/tests/modules/post-compiler.test.ts b/packages/devextreme-themebuilder/tests/modules/post-compiler.test.ts index 576834f8c4d0..41717ff21294 100644 --- a/packages/devextreme-themebuilder/tests/modules/post-compiler.test.ts +++ b/packages/devextreme-themebuilder/tests/modules/post-compiler.test.ts @@ -38,6 +38,23 @@ describe('PostCompiler', () => { + 'css'); }); + const themeBuilderInfoHeader = '/** Generated by the DevExpress ThemeBuilder\n' + + '* Version: 1.1.1\n' + + '* http://js.devexpress.com/ThemeBuilder/\n' + + '*/\n\n'; + + test('addInfoHeader - append after body, @charset glued to :root (CompileManager parity)', () => { + expect(addInfoHeader('@charset "utf-8";:root{}', '1.1.1', true)) + .toBe('@charset "UTF-8";:root{}\n' + + themeBuilderInfoHeader); + }); + + test('addInfoHeader - append after body, strips newline between @charset and @import', () => { + expect(addInfoHeader('@charset "UTF-8";\n@import url(https://example.com/a.css);', '1.1.1', true)) + .toBe('@charset "UTF-8";@import url(https://example.com/a.css);\n' + + themeBuilderInfoHeader); + }); + test('cleanCss', async () => { expect(await cleanCss('.c1 { color: #F00; } .c2 { color: #F00; }')) .toBe('.c1,\n.c2 {\n color: red;\n}'); diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 5f26c43f09d0..1d837b84d6f6 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -233,7 +233,7 @@ "build:testcafe": "cross-env DEVEXTREME_TEST_CI=TRUE BUILD_ESM_PACKAGE=true BUILD_TESTCAFE=TRUE gulp default", "build-npm-devextreme": "cross-env BUILD_ESM_PACKAGE=true gulp default", "build-dist": "cross-env BUILD_ESM_PACKAGE=true gulp default --uglify", - "build-themes": "pnpm --workspace-root nx run devextreme-scss:build:themes && pnpm --workspace-root nx run devextreme-scss:copy:assets", + "build-themes": "pnpm --workspace-root nx build:themes devextreme-scss && pnpm --workspace-root nx copy:assets devextreme-scss", "build:react": "gulp generate-react", "build:react:watch": "gulp generate-react-watch", "build:react:typescript": "gulp generate-react-typescript", diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts index c7c8c46bd971..3cb469341d10 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts @@ -64,6 +64,24 @@ function createMockModules(workspaceRoot: string, projectRoot: string): void { ].join('\n'), 'utf8', ); + + const chokidarDir = path.join(workspaceNodeModules, 'chokidar'); + fs.mkdirSync(chokidarDir, { recursive: true }); + fs.writeFileSync( + path.join(chokidarDir, 'index.js'), + [ + 'module.exports = {', + ' watch: function watch() {', + ' return {', + ' on: function on() { return this; },', + ' close: function close() { return Promise.resolve(); },', + ' };', + ' },', + '};', + '', + ].join('\n'), + 'utf8', + ); } async function setupProjectStructure(workspaceRoot: string): Promise { diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts index 75fdeeee4d9d..583a3ba7cc96 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -30,6 +30,15 @@ interface BuildDependencies { sass: any; postcss: any; autoprefixer: (options?: { overrideBrowserslist?: string[] }) => any; + chokidar: { + watch: ( + paths: string | string[], + options?: Record, + ) => { + on: (event: string, handler: (...args: any[]) => void) => unknown; + close: () => Promise | void; + }; + }; CleanCss: new (options: unknown) => { minify: (input: string) => { styles: string } }; themeOptions: { getThemes: () => Array<[string, string, string, string?]> }; cleanCssSanitizeOptions: unknown; @@ -124,6 +133,7 @@ function loadDependencies(projectRoot: string): BuildDependencies { sass: projectRequire('sass-embedded'), postcss: workspaceRequire('postcss'), autoprefixer: workspaceRequire('autoprefixer'), + chokidar: workspaceRequire('chokidar'), CleanCss: workspaceRequire('clean-css'), themeOptions: projectRequire(path.resolve(projectRoot, 'build/theme-options.cjs')) as { getThemes: () => Array<[string, string, string, string?]>; @@ -288,6 +298,7 @@ async function runWatchBuild( const cssOutputDir = path.resolve(projectRoot, options.cssOutputDir || DEFAULT_CSS_OUTPUT_DIR); const watchDir = path.resolve(projectRoot, 'scss'); const watchBundleNames = getWatchBundleNames(options); + const minifyProfile: MinifyProfile = options.mode === 'ci' ? 'ci' : 'all'; const rebuild = async (): Promise => { await generateScssBundles(projectRoot, bundlesDir, deps); @@ -295,7 +306,7 @@ async function runWatchBuild( const sources = resolveSourcesByBundleNames(projectRoot, bundlesDir, watchBundleNames); for (const source of sources) { - await compileFile(source, cssOutputDir, 'all', deps, projectRoot); + await compileFile(source, cssOutputDir, minifyProfile, deps, projectRoot); } await copyAssets(projectRoot, cssOutputDir); @@ -331,15 +342,13 @@ async function runWatchBuild( }, 200); }; - const watcher = fs.watch(watchDir, { recursive: true }, (_eventType, fileName) => { - if (!fileName || !fileName.endsWith('.scss')) { - return; - } - scheduleRebuild(); + const watcher = deps.chokidar.watch(path.join(watchDir, '**/*.scss'), { + ignoreInitial: true, }); + watcher.on('all', scheduleRebuild); const stopWatcher = () => { - watcher.close(); + void watcher.close(); if (timer) { clearTimeout(timer); } From e84a339c8a0922324dd3e79270d62adcef9f6d31 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 6 May 2026 12:07:15 +0200 Subject: [PATCH 9/9] clean code --- packages/devextreme-themebuilder/src/modules/post-compiler.ts | 3 +-- packages/nx-infra-plugin/src/executors/scss-build/executor.ts | 4 ---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/devextreme-themebuilder/src/modules/post-compiler.ts b/packages/devextreme-themebuilder/src/modules/post-compiler.ts index 0ecc5a5f01fd..b0357609a572 100644 --- a/packages/devextreme-themebuilder/src/modules/post-compiler.ts +++ b/packages/devextreme-themebuilder/src/modules/post-compiler.ts @@ -26,10 +26,9 @@ export function addInfoHeader( const header = buildThemeBuilderInfoHeader(version); const source = css.toString(); const encoding = '@charset "UTF-8";'; - - // clean-css may emit @charset immediately followed by :root / @import with no newline. const charsetPrefix = /^@charset\s+"utf-8";\s*/i; const match = source.match(charsetPrefix); + if (match) { const rest = source.slice(match[0].length).trimStart(); diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts index 583a3ba7cc96..041e1096bf81 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -78,10 +78,6 @@ function createStarLicenseHeader(fileName: string, version: string): string { ].join('\n'); } -/** - * Mirrors `style-compiler.js`: starLicense prepend, then - * `.replace(/([\s\S]*)(@charset.*?;\s)/, '$2$1')` so `@charset` is the first bytes of output. - */ function prependLicenseAndMoveCharsetFirst(minifiedCss: string, license: string): string { const withLicense = `${license}${minifiedCss}`; return withLicense.replace(/([\s\S]*)(@charset[^;]+;\s*)/, '$2$1');