diff --git a/.github/workflows/themebuilder_tests.yml b/.github/workflows/themebuilder_tests.yml index da7ccceed35d..2b34ccd1d856 100644 --- a/.github/workflows/themebuilder_tests.yml +++ b/.github/workflows/themebuilder_tests.yml @@ -52,7 +52,7 @@ jobs: - name: Build etalon bundles working-directory: ./packages/devextreme-scss - run: pnpm exec gulp style-compiler-themes-ci + run: pnpm --workspace-root nx build:ci devextreme-scss - 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..5e0b4e5c071b 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 --target=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-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..b0357609a572 100644 --- a/packages/devextreme-themebuilder/src/modules/post-compiler.ts +++ b/packages/devextreme-themebuilder/src/modules/post-compiler.ts @@ -10,19 +10,38 @@ 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";'; + const charsetPrefix = /^@charset\s+"utf-8";\s*/i; + const match = source.match(charsetPrefix); + + if (match) { + const rest = source.slice(match[0].length).trimStart(); - if (source.startsWith(encoding)) { - return `${encoding}\n${header}${source.replace(`${encoding}\n`, '')}`; + 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/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 71d919974a68..1d837b84d6f6 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -233,7 +233,7 @@ "build:testcafe": "cross-env DEVEXTREME_TEST_CI=TRUE BUILD_ESM_PACKAGE=true BUILD_TESTCAFE=TRUE gulp default", "build-npm-devextreme": "cross-env BUILD_ESM_PACKAGE=true gulp default", "build-dist": "cross-env BUILD_ESM_PACKAGE=true gulp default --uglify", - "build-themes": "gulp style-compiler-themes", + "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/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..3cb469341d10 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts @@ -0,0 +1,206 @@ +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', + ); + + 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 { + 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..041e1096bf81 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -0,0 +1,377 @@ +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; 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: