diff --git a/.github/workflows/themebuilder_tests.yml b/.github/workflows/themebuilder_tests.yml index da7ccceed35d..d5180e339cd1 100644 --- a/.github/workflows/themebuilder_tests.yml +++ b/.github/workflows/themebuilder_tests.yml @@ -52,7 +52,7 @@ jobs: - name: Build etalon bundles working-directory: ./packages/devextreme-scss - run: pnpm exec gulp style-compiler-themes-ci + run: pnpm --workspace-root nx run devextreme-scss:build:ci - name: Build working-directory: ./packages/devextreme-themebuilder diff --git a/packages/devextreme-scss/build/gulp-data-uri.js b/packages/devextreme-scss/build/gulp-data-uri.js deleted file mode 100644 index 699d1a4d51c2..000000000000 --- a/packages/devextreme-scss/build/gulp-data-uri.js +++ /dev/null @@ -1,46 +0,0 @@ -import path, { dirname } from 'path'; -import fs from 'fs'; -import sass from 'sass-embedded'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const dataUriRegex = /data-uri\((?:'(image\/svg\+xml;charset=UTF-8)',\s)?['"]?([^)'"]+)['"]?\)/g; - -const svg = (buffer, svgEncoding) => { - const encoding = svgEncoding || 'image/svg+xml;charset=UTF-8'; - const svg = encodeURIComponent(buffer.toString()); - - return `"data:${encoding},${svg}"`; -}; - -const img = (buffer, ext) => { - return `"data:image/${ext};base64,${buffer.toString('base64')}"`; -}; - -const handler = (_, svgEncoding, fileName) => { - const relativePath = path.join(__dirname, '..', fileName); - const filePath = path.resolve(relativePath); - const ext = filePath.split('.').pop(); - const data = fs.readFileSync(filePath); - const buffer = Buffer.from(data); - const escapedString = ext === 'svg' ? svg(buffer, svgEncoding) : img(buffer, ext); - return `url(${escapedString})`; -}; - -const sassFunction = (args) => { - const getTextFromSass = (sassValue) => sassValue.assertString().text; - const argList = args[0].asList; - const hasEncoding = argList.size === 2; - const encoding = hasEncoding ? getTextFromSass(argList.get(0)) : null; - const url = getTextFromSass(argList.get(hasEncoding ? 1 : 0)); - - return new sass.SassString(handler(null, encoding, url), { quotes: false }); -}; - -export const resolveDataUri = (content) => content.replace(dataUriRegex, handler); - -export const sassFunctions = { - 'data-uri($args...)': sassFunction, -}; diff --git a/packages/devextreme-scss/build/style-compiler.js b/packages/devextreme-scss/build/style-compiler.js deleted file mode 100644 index 29f70deab20f..000000000000 --- a/packages/devextreme-scss/build/style-compiler.js +++ /dev/null @@ -1,164 +0,0 @@ -import gulp from 'gulp'; -const { task, src, parallel, series, dest, watch } = gulp; - -import { join } from 'path'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import replace from 'gulp-replace'; -import plumber from 'gulp-plumber'; -import gulpSass from 'gulp-sass'; -import sassEmbedded from 'sass-embedded'; -import CleanCss from 'clean-css'; -import through from 'through2'; -import parseArguments from 'minimist'; -import autoprefixer from 'gulp-autoprefixer'; -import { createRequire } from 'module'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const require = createRequire(import.meta.url); -const cleanCssSanitizeOptions = require('./clean-css-options.json'); -const cleanCssOptions = require('../../devextreme-themebuilder/src/data/clean-css-options.json'); -const { starLicense } = require('../../devextreme/build/gulp/header-pipes.js'); - -const { getThemes } = require('./theme-options.cjs'); -import { sassFunctions } from './gulp-data-uri.js'; - -const sass = gulpSass(sassEmbedded); - -const cssArtifactsPath = join(process.cwd(), '..', 'devextreme', 'artifacts', 'css'); - -const DEFAULT_DEV_BUNDLE_NAMES = [ - 'light', - 'light.compact', - 'dark', - 'contrast', - 'material.blue.light', - 'material.blue.light.compact', - 'material.blue.dark', - 'fluent.blue.light', - 'fluent.blue.light.compact', - 'fluent.blue.dark', - 'fluent.saas.light', - 'fluent.saas.dark', -]; - -const getBundleSourcePath = name => `scss/bundles/dx.${name}.scss`; - -const compileBundles = (bundles, isDevBundle) => { - return src(bundles) - .pipe(plumber(e => { - console.log(e); - this.emit('end'); - })) - .on('data', (chunk) => console.log('Build: ', chunk.path)) - .pipe(sass({ - functions: sassFunctions - })) - .pipe(autoprefixer()) - .pipe(through.obj((file, enc, callback) => { - const content = file.contents.toString(); - new CleanCss(isDevBundle ? cleanCssOptions : cleanCssSanitizeOptions).minify(content, (_, css) => { - file.contents = new Buffer.from(css.styles); - callback(null, file); - }); - })) - .pipe(starLicense()) - .pipe(replace(/([\s\S]*)(@charset.*?;\s)/, '$2$1')) - .pipe(dest(cssArtifactsPath)); -}; - -function saveBundleFile(folder, fileName, content) { - const bundlePath = join(folder, fileName); - if(!existsSync(folder)) mkdirSync(folder); - writeFileSync(bundlePath, content); -} - -function generateScssBundleName(theme, size, color, mode) { - return 'dx' + - (theme === 'material' || theme === 'fluent' - ? `.${theme}` - : '') - + `.${color}` + - (mode ? `.${mode}` : '') + - (size === 'default' ? '' : '.compact') + - '.scss'; -} - -function generateScssBundles(bundlesFolder, getBundleContent) { - const saveBundle = (theme, size, color, mode) => { - const bundleName = generateScssBundleName(theme, size, color, mode); - const content = getBundleContent(theme, size, color, mode); - - saveBundleFile(bundlesFolder, bundleName, content); - }; - - getThemes().forEach(([theme, size, color, mode]) => saveBundle(theme, size, color, mode)); -} - -function createBundles(callback) { - const bundlesFolder = join(process.cwd(), 'scss', 'bundles'); - const readTemplate = (theme) => readFileSync(join(__dirname, `bundle-template.${theme}.scss`), 'utf8'); - const getBundleContent = (theme, size, color, mode) => { - const bundleTemplate = readTemplate(theme); - const bundleContent = bundleTemplate - .replace('$COLOR', color) - .replace('$SIZE', size) - .replace('$MODE', mode); - return bundleContent; - }; - - generateScssBundles(bundlesFolder, getBundleContent); - saveBundleFile(bundlesFolder, 'dx.common.scss', readTemplate('common')); - - if(callback) callback(); -} - -task('create-scss-bundles', createBundles); - -task('copy-fonts-and-icons', () => { - return src(['fonts/**/*', 'icons/**/*'], { base: '.' }) - .pipe(dest(cssArtifactsPath)); -}); - -task('compile-themes-all', () => compileBundles(getBundleSourcePath('*'))); -task('compile-themes-dev', () => compileBundles(DEFAULT_DEV_BUNDLE_NAMES.map(getBundleSourcePath), true)); - -task('style-compiler-themes', series( - 'create-scss-bundles', - parallel( - 'compile-themes-all', - 'copy-fonts-and-icons' - ) -)); - -task('style-compiler-themes-ci', series( - 'create-scss-bundles', - parallel( - 'compile-themes-dev', - 'copy-fonts-and-icons' - ) -)); - -task('style-compiler-themes-watch', () => { - const args = parseArguments(process.argv); - const bundlesArg = args['bundles']; - - const bundles = ( - bundlesArg - ? bundlesArg.split(',') - : DEFAULT_DEV_BUNDLE_NAMES) - .map((bundle) => { - const sourcePath = getBundleSourcePath(bundle); - if(existsSync(sourcePath)) { - return sourcePath; - } - console.log(`${sourcePath} file does not exists`); - return null; - }); - - watch('scss/**/*', parallel(() => compileBundles(bundles), 'copy-fonts-and-icons')) - .on('ready', () => console.log('style-compiler-themes task is watching for changes...')); -}); diff --git a/packages/devextreme-scss/gulpfile.js b/packages/devextreme-scss/gulpfile.js deleted file mode 100644 index 0494cad08db7..000000000000 --- a/packages/devextreme-scss/gulpfile.js +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-env node */ -/* eslint-disable no-console */ - -import gulp from 'gulp'; -import cache from 'gulp-cache'; -import { createRequire } from 'module'; - -const require = createRequire(import.meta.url); -const env = require('../devextreme/build/gulp/env-variables.js'); -const del = require('del'); - -gulp.task('clean', function(callback) { - del.sync([ - '../devextreme/artifacts/css/**', - '../devextreme/scss/bundles/**' - ], { force: true }); - cache.clearAll(); - callback(); -}); - -import './build/style-compiler.js'; - -if(env.TEST_CI) { - console.warn('Using test CI mode!'); -} - -function createStyleCompilerBatch() { - return gulp.series( - 'clean', - env.TEST_CI - ? ['style-compiler-themes-ci'] - : ['style-compiler-themes'] - ); -} - -gulp.task('default', createStyleCompilerBatch()); - -gulp.task('watch', gulp.series( - 'style-compiler-themes-watch' -)); diff --git a/packages/devextreme-scss/package.json b/packages/devextreme-scss/package.json index 6d80381a2f0b..db8a35022966 100644 --- a/packages/devextreme-scss/package.json +++ b/packages/devextreme-scss/package.json @@ -3,27 +3,17 @@ "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": { - "build": "gulp", + "build": "pnpm --workspace-root nx build devextreme-scss", "lint": "stylelint scss/widgets", "test": "jest --no-coverage --runInBand --config=./tests/jest.config.json", - "watch": "gulp watch" + "watch": "pnpm --workspace-root nx run devextreme-scss:watch" }, "version": "26.1.0" } diff --git a/packages/devextreme-scss/project.json b/packages/devextreme-scss/project.json index 55afe0dfdc3d..f720e5dfbfdd 100644 --- a/packages/devextreme-scss/project.json +++ b/packages/devextreme-scss/project.json @@ -4,18 +4,120 @@ "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": "devextreme-nx-infra-plugin:scss-build", + "options": { + "mode": "all" + }, + "inputs": [ + "{projectRoot}/build/**/*", + "{projectRoot}/images/**/*", + "{projectRoot}/scss/**/*" + ], + "outputs": [ + "{projectRoot}/scss/bundles", + "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css" + ], + "cache": true + }, + "build:themes-dev": { + "executor": "devextreme-nx-infra-plugin:scss-build", + "options": { + "mode": "ci" + }, + "inputs": [ + "{projectRoot}/build/**/*", + "{projectRoot}/images/**/*", + "{projectRoot}/scss/**/*" + ], + "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/**/*" + ], + "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/**/*", "{projectRoot}/fonts/**/*", "{projectRoot}/icons/**/*", "{projectRoot}/images/**/*", - "{projectRoot}/scss/**/*", - "{projectRoot}/gulpfile.js" + "{projectRoot}/scss/**/*" ], "outputs": [ "{projectRoot}/scss/bundles", @@ -25,6 +127,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/devextreme/package.json b/packages/devextreme/package.json index 71d919974a68..5f26c43f09d0 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -233,7 +233,7 @@ "build:testcafe": "cross-env DEVEXTREME_TEST_CI=TRUE BUILD_ESM_PACKAGE=true BUILD_TESTCAFE=TRUE gulp default", "build-npm-devextreme": "cross-env BUILD_ESM_PACKAGE=true gulp default", "build-dist": "cross-env BUILD_ESM_PACKAGE=true gulp default --uglify", - "build-themes": "gulp style-compiler-themes", + "build-themes": "pnpm --workspace-root nx run devextreme-scss:build:themes && pnpm --workspace-root nx run devextreme-scss:copy:assets", "build:react": "gulp generate-react", "build:react:watch": "gulp generate-react-watch", "build:react:typescript": "gulp generate-react-typescript", diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index e09768781710..c70cec69d154 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -89,6 +89,11 @@ "implementation": "./src/executors/compress/executor", "schema": "./src/executors/compress/schema.json", "description": "Compress JavaScript files" + }, + "scss-build": { + "implementation": "./src/executors/scss-build/executor", + "schema": "./src/executors/scss-build/schema.json", + "description": "Run SCSS themes build pipeline in all or CI mode" } } } diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts new file mode 100644 index 000000000000..c7c8c46bd971 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts @@ -0,0 +1,188 @@ +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', () => { + 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 new file mode 100644 index 000000000000..b2f89da64fb5 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -0,0 +1,378 @@ +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; + CleanCss: new (options: unknown) => { minify: (input: string) => { styles: string } }; + themeOptions: { getThemes: () => Array<[string, string, string, string?]> }; + cleanCssSanitizeOptions: unknown; + cleanCssDevOptions: unknown; + devextremeVersion: string; + browsersList: 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')}`; +} + +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].trim(); + const withoutCharset = css.replace(match[0], '').replace(/^\s+/, ''); + + 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, + browsersList: workspaceRequire(path.resolve(projectRoot, '../devextreme/package.json')) + .browserslist, + }; +} + +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({ + overrideBrowserslist: deps.browsersList, + }), + ]).process(compiled.css, { + from: undefined, + }); + + 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); + 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 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, '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 }); + }; + + 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; 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..46150b76b51a --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/schema.json @@ -0,0 +1,48 @@ +{ + "$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"] + }, + "bundlesDir": { + "type": "string", + "description": "Generated SCSS bundles directory relative to project root", + "default": "./scss/bundles" + }, + "cssOutputDir": { + "type": "string", + "description": "Output CSS artifacts directory relative to project root", + "default": "../devextreme/artifacts/css" + }, + "devBundles": { + "type": "array", + "description": "Bundle names used in CI mode", + "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 new file mode 100644 index 000000000000..cbbcb5dccd3d --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/schema.ts @@ -0,0 +1,8 @@ +export interface ScssBuildExecutorSchema { + mode: 'all' | 'ci'; + bundlesDir?: string; + cssOutputDir?: string; + devBundles?: string[]; + watch?: boolean; + bundles?: string[] | string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 345327887735..e0debef85932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2105,33 +2105,6 @@ importers: clean-css: specifier: 5.3.3 version: 5.3.3 - del: - specifier: 2.2.2 - version: 2.2.2 - gulp: - specifier: 4.0.2 - version: 4.0.2 - gulp-autoprefixer: - specifier: 10.0.0 - version: 10.0.0(gulp@4.0.2) - gulp-cache: - specifier: 1.1.3 - version: 1.1.3 - gulp-plumber: - specifier: 1.2.1 - version: 1.2.1 - gulp-replace: - specifier: 0.6.1 - version: 0.6.1 - gulp-sass: - specifier: 6.0.1 - version: 6.0.1 - gulp-shell: - specifier: 0.8.0 - version: 0.8.0 - minimist: - specifier: 1.2.8 - version: 1.2.8 sass-embedded: specifier: 1.93.3 version: 1.93.3 @@ -2144,9 +2117,6 @@ importers: stylelint-scss: specifier: 6.10.0 version: 6.10.0(stylelint@15.11.0(typescript@5.9.3)) - through2: - specifier: 2.0.5 - version: 2.0.5 ts-jest: specifier: 29.1.2 version: 29.1.2(@babel/core@7.29.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest@30.2.0(@types/node@20.19.37)(babel-plugin-macros@3.1.0)(node-notifier@9.0.1)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) @@ -15037,6 +15007,7 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qified@0.9.1: