From b24051eb6983bd8fecd10957c884161111ba7e2f Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 29 Apr 2026 22:32:51 +0200 Subject: [PATCH 01/13] 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 df871d0ba98c4d8a3a2087ee2af102224c6380dd Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 29 Apr 2026 22:39:53 +0200 Subject: [PATCH 02/13] 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 8672e3559e1c..7e18ec85606a 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -119,6 +119,11 @@ "implementation": "./src/executors/state-manager-optimize/executor", "schema": "./src/executors/state-manager-optimize/schema.json", "description": "Optimize state_manager modules for production builds" + }, + "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 d24aa0ae571ada1dc5963b746dd29f4900a252bb Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 29 Apr 2026 22:57:12 +0200 Subject: [PATCH 03/13] 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 93b86d64ea27bf4e7b642d74346ef1f82326a730 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 29 Apr 2026 23:36:36 +0200 Subject: [PATCH 04/13] 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 7f9acb9573c1..4b6c18f6e3f3 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.2" } 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 4b31bab9722918ef89850996eed26e55d60374b3 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Thu, 30 Apr 2026 00:06:25 +0200 Subject: [PATCH 05/13] 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 | 30 ---- 8 files changed, 4 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 bd22d691b811..40554c1d8780 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 4b6c18f6e3f3..2f7a4a3c34a8 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 e5fcedb75b3c..6238f00ad6dc 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -231,7 +231,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 c578ed08dca9..23741402a798 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2122,33 +2122,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 @@ -2161,9 +2134,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@25.7.0)(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@25.7.0)(typescript@5.9.3)))(typescript@5.9.3) From 87774e33cdb0f295d6a0d46d1089a556057ff38c Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Thu, 30 Apr 2026 00:26:08 +0200 Subject: [PATCH 06/13] 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 19a309dc8e4d8c93ea9d4903ceac1e90b3eb25d3 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Thu, 30 Apr 2026 13:35:23 +0200 Subject: [PATCH 07/13] 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 bd609270f9e262e00125dfe39acb04a32716bf96 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Tue, 5 May 2026 23:51:45 +0200 Subject: [PATCH 08/13] 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 40554c1d8780..45d9c5ce31fc 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 2f7a4a3c34a8..234910000bf6 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.2" } 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 6238f00ad6dc..4fe4400be758 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -231,7 +231,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 c6aee4694219e6efc8b79cfb0e3a5ac8c3b1b57b Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 6 May 2026 12:07:15 +0200 Subject: [PATCH 09/13] 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'); From 8e289f4ce2611a1ef520dcbecc98eee705adb52b Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 13 May 2026 23:47:36 +0200 Subject: [PATCH 10/13] refactor and optimization --- .github/workflows/themebuilder_tests.yml | 2 +- packages/devextreme-scss/package.json | 4 ++-- packages/devextreme-scss/project.json | 18 ++++++++++-------- packages/devextreme/package.json | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/themebuilder_tests.yml b/.github/workflows/themebuilder_tests.yml index 45d9c5ce31fc..30fcfca25d03 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 build:ci devextreme-scss + run: pnpm 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 234910000bf6..b22fe1e401ef 100644 --- a/packages/devextreme-scss/package.json +++ b/packages/devextreme-scss/package.json @@ -10,10 +10,10 @@ "ts-jest": "29.1.2" }, "scripts": { - "build": "pnpm --workspace-root nx build devextreme-scss", + "build": "pnpm 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 --target=watch" + "watch": "pnpm nx run devextreme-scss --target=watch" }, "version": "26.1.2" } diff --git a/packages/devextreme-scss/project.json b/packages/devextreme-scss/project.json index f720e5dfbfdd..aa76b5144363 100644 --- a/packages/devextreme-scss/project.json +++ b/packages/devextreme-scss/project.json @@ -20,8 +20,8 @@ "executor": "nx:run-commands", "options": { "commands": [ - "pnpm --workspace-root nx clean:artifacts devextreme-scss", - "pnpm --workspace-root nx clean:bundles devextreme-scss" + "pnpm nx clean:artifacts devextreme-scss", + "pnpm nx clean:bundles devextreme-scss" ], "parallel": false } @@ -52,6 +52,8 @@ }, "inputs": [ "{projectRoot}/build/**/*", + "{projectRoot}/fonts/**/*", + "{projectRoot}/icons/**/*", "{projectRoot}/images/**/*", "{projectRoot}/scss/**/*" ], @@ -68,6 +70,8 @@ }, "inputs": [ "{projectRoot}/build/**/*", + "{projectRoot}/fonts/**/*", + "{projectRoot}/icons/**/*", "{projectRoot}/images/**/*", "{projectRoot}/scss/**/*" ], @@ -81,9 +85,8 @@ "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" + "pnpm nx clean devextreme-scss", + "pnpm nx run-many --targets=build:themes,copy:assets --projects=devextreme-scss --parallel" ], "parallel": false }, @@ -106,9 +109,8 @@ "executor": "nx:run-commands", "options": { "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" + "pnpm nx clean devextreme-scss", + "pnpm nx run-many --targets=build:themes-dev,copy:assets --projects=devextreme-scss --parallel" ], "parallel": false }, diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 4fe4400be758..73cb71e17c7f 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -231,7 +231,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 build:themes devextreme-scss && pnpm --workspace-root nx copy:assets devextreme-scss", + "build-themes": "pnpm nx build:themes devextreme-scss && pnpm nx copy:assets devextreme-scss", "build:react": "gulp generate-react", "build:react:watch": "gulp generate-react-watch", "build:react:typescript": "gulp generate-react-typescript", From 227bb516ae4f6d71aec071b10947b05dbbd11927 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Tue, 19 May 2026 15:19:44 +0200 Subject: [PATCH 11/13] devextreme-scss build refactor --- packages/devextreme/package.json | 2 +- .../scss-assemble/scss-assemble.impl.ts | 23 +- .../src/executors/scss-build/executor.ts | 378 +----------------- .../executors/scss-build/scss-build.impl.ts | 378 ++++++++++++++++++ .../src/utils/scss-data-uri.spec.ts | 22 + .../src/utils/scss-data-uri.ts | 33 ++ 6 files changed, 442 insertions(+), 394 deletions(-) create mode 100644 packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts create mode 100644 packages/nx-infra-plugin/src/utils/scss-data-uri.spec.ts create mode 100644 packages/nx-infra-plugin/src/utils/scss-data-uri.ts diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 73cb71e17c7f..7d36196e44ee 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -231,7 +231,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 nx build:themes devextreme-scss && pnpm nx copy:assets devextreme-scss", + "build-themes": "pnpm nx build 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-assemble/scss-assemble.impl.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts index 3c41547c3547..cc1984f481ae 100644 --- 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 @@ -6,23 +6,16 @@ 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 { + DATA_URI_SCSS_REGEX, + encodeDataUriForCssUrl, +} from '../../utils/scss-data-uri'; 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)]; + const matches = [...content.matchAll(DATA_URI_SCSS_REGEX)]; if (matches.length === 0) return content; const replacements = new Map(); @@ -35,15 +28,13 @@ async function inlineDataUri(content: string, scssRoot: string): Promise 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); + const escapedString = encodeDataUriForCssUrl(buffer, filePath, svgEncoding); replacements.set(matchStr, `url(${escapedString})`); }), ); - return content.replace(DATA_URI_REGEX, (match) => replacements.get(match) ?? match); + return content.replace(DATA_URI_SCSS_REGEX, (match) => replacements.get(match) ?? match); } async function copyScssWithInlineDataUri( 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 041e1096bf81..8c0bdaed7166 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -1,377 +1 @@ -import { PromiseExecutor, logger } from '@nx/devkit'; -import * as fs from 'fs'; -import * as path from 'path'; -import { createRequire } from 'module'; -import { glob } from 'glob'; -import { ScssBuildExecutorSchema } from './schema'; -import { normalizeGlobPathForWindows, resolveProjectPath } from '../../utils/path-resolver'; -import { ensureDir, readFileText, writeFileText } from '../../utils/file-operations'; - -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', -]; - -const EULA_URL = 'https://js.devexpress.com/Licensing/'; - -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; - cleanCssDevOptions: unknown; - devextremeVersion: string; -} - -type MinifyProfile = 'all' | 'ci'; - -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')}`; -} - -/** - * 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()}`, - '*', - `* Copyright (c) 2012 - ${new Date().getFullYear()} Developer Express Inc. ALL RIGHTS RESERVED`, - `* Read about DevExtreme licensing here: ${EULA_URL}`, - '*/', - '', - ].join('\n'); -} - -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' - + (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'), - chokidar: workspaceRequire('chokidar'), - 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 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, -): 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`))); - } - - const pattern = normalizeGlobPathForWindows(path.join(bundlesDir, 'dx.*.scss')); - return glob(pattern, { 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, - minifyProfile: MinifyProfile, - 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: sourceFile, - }); - - 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 license = createStarLicenseHeader(outFileName, deps.devextremeVersion); - const withHeader = prependLicenseAndMoveCharsetFirst(minified, license); - await writeFileText(path.join(outputDir, outFileName), withHeader); -} - -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); - - 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 minifyProfile: MinifyProfile = options.mode === 'ci' ? 'ci' : 'all'; - - const rebuild = async (): Promise => { - await generateScssBundles(projectRoot, bundlesDir, deps); - await ensureDir(cssOutputDir); - - const sources = resolveSourcesByBundleNames(projectRoot, bundlesDir, watchBundleNames); - for (const source of sources) { - await compileFile(source, cssOutputDir, minifyProfile, 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 = deps.chokidar.watch(path.join(watchDir, '**/*.scss'), { - ignoreInitial: true, - }); - watcher.on('all', scheduleRebuild); - - const stopWatcher = () => { - void watcher.close(); - if (timer) { - clearTimeout(timer); - } - resolve({ success: true }); - }; - - 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); - logger.error(`SCSS build failed: ${message}`); - return { success: false }; - } -}; - -export default runExecutor; +export { default } from './scss-build.impl'; diff --git a/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts b/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts new file mode 100644 index 000000000000..1440e3784af5 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts @@ -0,0 +1,378 @@ +import { logger } from '@nx/devkit'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createRequire } from 'module'; +import { glob } from 'glob'; +import { createExecutor } from '../../utils/create-executor'; +import { normalizeGlobPathForWindows } from '../../utils/path-resolver'; +import { ensureDir, exists, readFileText, writeFileText } from '../../utils/file-operations'; +import { encodeDataUriContent } from '../../utils/scss-data-uri'; +import { DEFAULT_EULA_URL } from '../add-license-headers/defaults'; +import { copyDirectory } from '../copy-files/copy-files.impl'; +import { ScssBuildExecutorSchema } from './schema'; + +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', +]; + +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; + cleanCssDevOptions: unknown; + devextremeVersion: string; +} + +type MinifyProfile = 'all' | 'ci'; + +interface ResolvedScssBuild { + projectRoot: string; + options: ScssBuildExecutorSchema; + deps: BuildDependencies; +} + +function readFileDataUri(filePath: string, svgEncoding?: string): string { + const buffer = fs.readFileSync(filePath); + return encodeDataUriContent(buffer, filePath, svgEncoding); +} + +/** + * 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()}`, + '*', + `* Copyright (c) 2012 - ${new Date().getFullYear()} Developer Express Inc. ALL RIGHTS RESERVED`, + `* Read about DevExtreme licensing here: ${DEFAULT_EULA_URL}`, + '*/', + '', + ].join('\n'); +} + +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' + + (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'), + chokidar: workspaceRequire('chokidar'), + 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 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, +): 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`))); + } + + const pattern = normalizeGlobPathForWindows(path.join(bundlesDir, 'dx.*.scss')); + return glob(pattern, { 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 = readFileDataUri(absolutePath, encoding); + return new sass.SassString(`url("${dataUri}")`, { quotes: false }); + }; +} + +async function compileFile( + sourceFile: string, + outputDir: string, + minifyProfile: MinifyProfile, + 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: sourceFile, + }); + + 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 license = createStarLicenseHeader(outFileName, deps.devextremeVersion); + const withHeader = prependLicenseAndMoveCharsetFirst(minified, license); + await writeFileText(path.join(outputDir, outFileName), withHeader); +} + +async function copyThemeAssets(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'); + + await Promise.all([ + (async () => { + if (await exists(fontsFrom)) { + await copyDirectory(fontsFrom, fontsTo); + } + })(), + (async () => { + if (await exists(iconsFrom)) { + await copyDirectory(iconsFrom, iconsTo); + } + })(), + ]); +} + +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); + + 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 { + 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 minifyProfile: MinifyProfile = options.mode === 'ci' ? 'ci' : 'all'; + + const rebuild = async (): Promise => { + await generateScssBundles(projectRoot, bundlesDir, deps); + await ensureDir(cssOutputDir); + + const sources = resolveSourcesByBundleNames(projectRoot, bundlesDir, watchBundleNames); + for (const source of sources) { + await compileFile(source, cssOutputDir, minifyProfile, deps, projectRoot); + } + + await copyThemeAssets(projectRoot, cssOutputDir); + }; + + await rebuild(); + logger.info('scss-build watch mode is watching for changes...'); + + await new Promise((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 = deps.chokidar.watch(path.join(watchDir, '**/*.scss'), { + ignoreInitial: true, + }); + watcher.on('all', scheduleRebuild); + + const stopWatcher = () => { + void watcher.close(); + if (timer) { + clearTimeout(timer); + } + resolve(); + }; + + process.once('SIGINT', stopWatcher); + process.once('SIGTERM', stopWatcher); + }); +} + +export default createExecutor({ + name: 'ScssBuild', + resolve: (options, { projectRoot }) => ({ + projectRoot, + options, + deps: loadDependencies(projectRoot), + }), + run: async ({ projectRoot, options, deps }) => { + if (options.watch) { + await runWatchBuild(projectRoot, options, deps); + return; + } + + await runSingleBuild(projectRoot, options, deps); + }, +}); diff --git a/packages/nx-infra-plugin/src/utils/scss-data-uri.spec.ts b/packages/nx-infra-plugin/src/utils/scss-data-uri.spec.ts new file mode 100644 index 000000000000..4bcc55bcbcd0 --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/scss-data-uri.spec.ts @@ -0,0 +1,22 @@ +import { encodeDataUriContent, encodeDataUriForCssUrl } from './scss-data-uri'; + +describe('scss-data-uri', () => { + it('encodes svg as utf-8 data uri', () => { + const buffer = Buffer.from(''); + expect(encodeDataUriContent(buffer, 'icon.svg')).toBe( + 'data:image/svg+xml;charset=UTF-8,%3Csvg%3E%3C%2Fsvg%3E', + ); + }); + + it('encodes raster images as base64', () => { + const buffer = Buffer.from('png-bytes'); + expect(encodeDataUriContent(buffer, 'icon.png')).toBe( + 'data:image/png;base64,cG5nLWJ5dGVz', + ); + }); + + it('wraps payload for css url() replacement', () => { + const buffer = Buffer.from('x'); + expect(encodeDataUriForCssUrl(buffer, 'a.png')).toBe('"data:image/png;base64,eA=="'); + }); +}); diff --git a/packages/nx-infra-plugin/src/utils/scss-data-uri.ts b/packages/nx-infra-plugin/src/utils/scss-data-uri.ts new file mode 100644 index 000000000000..73d49008a60c --- /dev/null +++ b/packages/nx-infra-plugin/src/utils/scss-data-uri.ts @@ -0,0 +1,33 @@ +import * as path from 'path'; + +/** + * Unquoted `data:` URI payload (used inside Sass/CSS `url("...")`). + */ +export function encodeDataUriContent( + buffer: Buffer, + filePathOrExt: string, + svgEncoding?: string, +): string { + const ext = path.extname(filePathOrExt).replace('.', '').toLowerCase(); + + if (ext === 'svg') { + const encoding = svgEncoding ?? 'image/svg+xml;charset=UTF-8'; + return `data:${encoding},${encodeURIComponent(buffer.toString())}`; + } + + return `data:image/${ext};base64,${buffer.toString('base64')}`; +} + +/** + * Quoted data URI for `url(...)` replacement when inlining in assembled SCSS sources. + */ +export function encodeDataUriForCssUrl( + buffer: Buffer, + filePathOrExt: string, + svgEncoding?: string, +): string { + return `"${encodeDataUriContent(buffer, filePathOrExt, svgEncoding)}"`; +} + +export const DATA_URI_SCSS_REGEX = + /data-uri\((?:'(image\/svg\+xml;charset=UTF-8)',\s)?['"]?([^)'"]+)['"]?\)/g; From dfb4d000b7154a624ae7b3eabab14fdf5c013281 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Tue, 26 May 2026 22:04:28 +0200 Subject: [PATCH 12/13] fix lint --- .../src/executors/scss-assemble/scss-assemble.impl.ts | 5 +---- packages/nx-infra-plugin/src/utils/scss-data-uri.spec.ts | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) 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 index cc1984f481ae..b49778435343 100644 --- 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 @@ -6,10 +6,7 @@ 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 { - DATA_URI_SCSS_REGEX, - encodeDataUriForCssUrl, -} from '../../utils/scss-data-uri'; +import { DATA_URI_SCSS_REGEX, encodeDataUriForCssUrl } from '../../utils/scss-data-uri'; import { ScssAssembleExecutorSchema } from './schema'; const SCSS_EXTENSIONS = new Set(['.scss', '.css']); diff --git a/packages/nx-infra-plugin/src/utils/scss-data-uri.spec.ts b/packages/nx-infra-plugin/src/utils/scss-data-uri.spec.ts index 4bcc55bcbcd0..a33abae0abaf 100644 --- a/packages/nx-infra-plugin/src/utils/scss-data-uri.spec.ts +++ b/packages/nx-infra-plugin/src/utils/scss-data-uri.spec.ts @@ -10,9 +10,7 @@ describe('scss-data-uri', () => { it('encodes raster images as base64', () => { const buffer = Buffer.from('png-bytes'); - expect(encodeDataUriContent(buffer, 'icon.png')).toBe( - 'data:image/png;base64,cG5nLWJ5dGVz', - ); + expect(encodeDataUriContent(buffer, 'icon.png')).toBe('data:image/png;base64,cG5nLWJ5dGVz'); }); it('wraps payload for css url() replacement', () => { From cd46d19df6f7c88af8c61d63e1c5f8627b8ddb03 Mon Sep 17 00:00:00 2001 From: Aliullov Vlad Date: Wed, 27 May 2026 13:46:16 +0200 Subject: [PATCH 13/13] fix review notes --- .../executors/scss-build/scss-build.impl.ts | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts b/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts index 1440e3784af5..5cfb9b5741a9 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts @@ -318,27 +318,37 @@ async function runWatchBuild( await new Promise((resolve) => { let timer: NodeJS.Timeout | undefined; let busy = false; + let pending = false; + + const runRebuild = async (): Promise => { + if (busy) { + pending = true; + 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; + if (pending) { + pending = false; + void runRebuild(); + } + } + }; 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; - } + timer = setTimeout(() => { + void runRebuild(); }, 200); };