From 7af5418ef0fcc4426751a0a6ad23daeb1b79aa70 Mon Sep 17 00:00:00 2001 From: Raushen Date: Thu, 13 Aug 2026 17:37:59 +0300 Subject: [PATCH 1/5] Fluent-next: ship accents as CSS, keep the sources out of the package --- packages/devextreme-scss/project.json | 8 ++- .../scss/widgets/fluent-next/_colors.scss | 6 +- packages/devextreme/project.json | 13 +++- .../scss-assemble/executor.e2e.spec.ts | 25 +++++++ .../src/executors/scss-assemble/schema.json | 7 ++ .../src/executors/scss-assemble/schema.ts | 1 + .../scss-assemble/scss-assemble.impl.ts | 10 +-- .../executors/scss-build/executor.e2e.spec.ts | 71 ++++++++++++++++++- .../src/executors/scss-build/executor.ts | 1 + .../executors/scss-build/scss-build.impl.ts | 52 ++++++++++++++ 10 files changed, 180 insertions(+), 14 deletions(-) diff --git a/packages/devextreme-scss/project.json b/packages/devextreme-scss/project.json index 456bbc34f092..32b5f91d250d 100644 --- a/packages/devextreme-scss/project.json +++ b/packages/devextreme-scss/project.json @@ -88,7 +88,8 @@ ], "outputs": [ "{projectRoot}/scss/bundles", - "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css" + "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css", + "{workspaceRoot}/packages/devextreme/artifacts/css/accents" ], "cache": true }, @@ -113,7 +114,8 @@ ], "outputs": [ "{projectRoot}/scss/bundles", - "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css" + "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css", + "{workspaceRoot}/packages/devextreme/artifacts/css/accents" ], "cache": true }, @@ -139,6 +141,7 @@ "outputs": [ "{projectRoot}/scss/bundles", "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css", + "{workspaceRoot}/packages/devextreme/artifacts/css/accents", "{workspaceRoot}/packages/devextreme/artifacts/css/fonts", "{workspaceRoot}/packages/devextreme/artifacts/css/icons" ], @@ -167,6 +170,7 @@ "outputs": [ "{projectRoot}/scss/bundles", "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css", + "{workspaceRoot}/packages/devextreme/artifacts/css/accents", "{workspaceRoot}/packages/devextreme/artifacts/css/fonts", "{workspaceRoot}/packages/devextreme/artifacts/css/icons" ], diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss index 782d2578a3ff..a0b0411576d5 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss @@ -5,13 +5,9 @@ $color: null !default; $mode: null !default; -$theme-marker-color: null !default; +$theme-marker-color: $color !default; $theme-marker-mode: null !default; -@if $color == "blue" { - $theme-marker-color: "blue" !default; -} - @if $mode == "light" { $theme-marker-mode: "light" !default; } diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 19efa18de2d0..6cc1354d93d4 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1226,13 +1226,24 @@ "executor": "devextreme-nx-infra-plugin:scss-assemble", "options": { "scssPackagePath": "../devextreme-scss", - "outputDir": "./artifacts/npm/devextreme/scss" + "outputDir": "./artifacts/npm/devextreme/scss", + "exclude": [ + "widgets/fluent-next/**", + "_design-system/**", + "bundles/dx.fluent-next.*.scss" + ] }, "configurations": { "internal": { "outputDir": "./artifacts/npm/devextreme-internal/scss" } }, + "dependsOn": [ + { + "projects": ["devextreme-scss"], + "target": "build:tokens" + } + ], "inputs": [ "{workspaceRoot}/packages/devextreme-scss/scss/**/*", "{workspaceRoot}/packages/devextreme-scss/fonts/**/*", diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts index bebf5fbea714..cb2b69e7bb26 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts @@ -83,4 +83,29 @@ describe('ScssAssembleExecutor E2E', () => { expect(content).toContain(expectedSvg); expect(content).toContain(expectedPng); }); + + it('should keep excluded sources out of the package', async () => { + await writeFileText( + path.join(scssPackageDir, 'scss', 'widgets', 'kept', '_index.scss'), + '.a {}', + ); + await writeFileText( + path.join(scssPackageDir, 'scss', 'widgets', 'dropped', '_index.scss'), + '.b {}', + ); + await writeFileText(path.join(scssPackageDir, 'scss', 'bundles', 'dx.kept.scss'), '.c {}'); + await writeFileText(path.join(scssPackageDir, 'scss', 'bundles', 'dx.dropped.scss'), '.d {}'); + + const context = createMockContext({ root: tempDir }); + const result = await executor( + { ...OPTIONS, exclude: ['widgets/dropped/**', 'bundles/dx.dropped.scss'] }, + context, + ); + + expect(result.success).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'widgets', 'kept', '_index.scss'))).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'bundles', 'dx.kept.scss'))).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'widgets', 'dropped'))).toBe(false); + expect(fs.existsSync(path.join(outputDir, 'bundles', 'dx.dropped.scss'))).toBe(false); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json index 3efa0803ccff..042520c8ebd4 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json @@ -11,6 +11,13 @@ "outputDir": { "type": "string", "description": "Output directory for assembled SCSS files (relative to project root)." + }, + "exclude": { + "type": "array", + "description": "Glob patterns, relative to the scss directory, kept out of the package.", + "items": { + "type": "string" + } } }, "required": ["scssPackagePath", "outputDir"] diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts index 196986ac5966..859d836d2d4c 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts @@ -1,4 +1,5 @@ export interface ScssAssembleExecutorSchema { scssPackagePath: string; outputDir: string; + exclude?: string[]; } diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts index b49778435343..175d296951b7 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 @@ -37,10 +37,11 @@ async function inlineDataUri(content: string, scssRoot: string): Promise async function copyScssWithInlineDataUri( scssPackagePath: string, outputDir: string, + exclude: string[], ): Promise { const scssSourceDir = path.join(scssPackagePath, 'scss'); const cwd = toPosixPath(scssSourceDir); - const relPaths = await glob('**/*', { cwd, nodir: true }); + const relPaths = await glob('**/*', { cwd, nodir: true, ignore: exclude }); await Promise.all( relPaths.map(async (relPath) => { @@ -77,6 +78,7 @@ async function copyIcons(scssPackagePath: string, outputDir: string): Promise({ @@ -84,11 +86,11 @@ export default createExecutor( resolve: (options, { projectRoot }) => { const scssPackagePath = path.resolve(projectRoot, options.scssPackagePath); const outputDir = path.resolve(projectRoot, options.outputDir); - return { scssPackagePath, outputDir }; + return { scssPackagePath, outputDir, exclude: options.exclude ?? [] }; }, - run: async ({ scssPackagePath, outputDir }) => { + run: async ({ scssPackagePath, outputDir, exclude }) => { await Promise.all([ - copyScssWithInlineDataUri(scssPackagePath, outputDir), + copyScssWithInlineDataUri(scssPackagePath, outputDir, exclude), copyFonts(scssPackagePath, outputDir), copyIcons(scssPackagePath, outputDir), ]); 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 10c6d1e9a037..84f7f0969c7a 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,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import executor from './executor'; +import executor, { findMissingThemeCss } from './executor'; import { ScssBuildExecutorSchema } from './schema'; import { createMockContext, createTempDir, cleanupTempDir } from '../../utils/test-utils'; import { writeFileText, writeJson, readFileText } from '../../utils'; @@ -29,7 +29,7 @@ function createMockModules(projectRoot: string): void { '}', 'module.exports = {', ' SassString,', - ' compile: () => ({ css: \'@charset "UTF-8"; .a{display:flex}\' })', + ' compile: () => ({ css: \'/**\\n * Do not edit directly, this file was auto-generated.\\n */\\n@charset "UTF-8"; .a{display:flex}\' })', '};', '', ].join('\n'), @@ -144,6 +144,11 @@ async function setupProjectStructure(workspaceRoot: string): Promise { '.generic-$COLOR { color: red; }', ); + await writeFileText( + path.join(projectRoot, 'scss', '_design-system', 'fluent', 'accents', 'blue.scss'), + ':root { --dxds-primary-100: #0f6cbd; }', + ); + createMockModules(projectRoot); return projectRoot; } @@ -189,6 +194,33 @@ describe('ScssBuildExecutor E2E', () => { expect(commonCss).toContain('DevExtreme (dx.common.css)'); }); + it('compiles design-system accent sources into the accents subfolder without minification', async () => { + const projectRoot = await setupProjectStructure(tempDir); + await writeFileText( + path.join(projectRoot, 'scss', '_design-system', 'fluent', 'accents', 'storm.scss'), + ':root { --dxds-primary-100: #6d6a68; }', + ); + + 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); + + const stormCss = await readFileText( + path.join(projectRoot, 'artifacts', 'css', 'accents', 'storm.css'), + ); + expect(stormCss).toContain('DevExtreme (storm.css)'); + expect(stormCss).not.toContain('/*min:'); + expect(stormCss).not.toContain('/*prefixed*/'); + expect(stormCss).not.toContain('auto-generated'); + }); + it('builds ci mode only for selected dev bundles and uses ci profile', async () => { const projectRoot = await setupProjectStructure(tempDir); const context = createMockContext({ @@ -219,6 +251,41 @@ describe('ScssBuildExecutor E2E', () => { expect(fs.existsSync(path.join(projectRoot, 'scss', 'bundles', 'dx.common.scss'))).toBe(true); }); + it('fails when the design-system produced no accent palettes', async () => { + const projectRoot = await setupProjectStructure(tempDir); + fs.rmSync(path.join(projectRoot, 'scss', '_design-system'), { recursive: true }); + + 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(false); + }); + + it('reports declared themes that left no CSS behind', async () => { + const projectRoot = await setupProjectStructure(tempDir); + const context = createMockContext({ + root: tempDir, + projectName: 'devextreme-scss', + projectRoot: 'packages/devextreme-scss', + }); + + await executor({ mode: 'all', cssOutputDir: './artifacts/css' }, context); + + const cssDir = path.join(projectRoot, 'artifacts', 'css'); + const deps = { themeOptions: { getThemes: () => [['generic', 'default', 'light']] } }; + + expect(findMissingThemeCss(cssDir, deps as never)).toEqual([]); + + fs.rmSync(path.join(cssDir, 'dx.light.css')); + expect(findMissingThemeCss(cssDir, deps as never)).toEqual(['dx.light.css']); + }); + it('fails in ci mode when a configured bundle source is missing', async () => { await setupProjectStructure(tempDir); const context = createMockContext({ 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 8c0bdaed7166..84dd0943568a 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -1 +1,2 @@ export { default } from './scss-build.impl'; +export { findMissingThemeCss } 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 index cb7979db0d1a..2ee59affd76d 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 @@ -13,6 +13,9 @@ import { ScssBuildExecutorSchema } from './schema'; const DEFAULT_BUNDLES_DIR = './scss/bundles'; const DEFAULT_CSS_OUTPUT_DIR = '../devextreme/artifacts/css'; +const ACCENT_SOURCES_DIR = './scss/_design-system/fluent/accents'; +const ACCENT_OUTPUT_DIR_NAME = 'accents'; +const GENERATOR_BANNER_REGEX = /^\s*\/\*[\s\S]*?auto-generated[\s\S]*?\*\/\s*/; const DEFAULT_DEV_BUNDLE_NAMES = [ 'light', 'light.compact', @@ -112,6 +115,19 @@ async function generateScssBundles( await writeFileText(path.join(resolvedBundlesDir, 'dx.common.scss'), commonTemplate); } +export function findMissingThemeCss(cssOutputDir: string, deps: BuildDependencies): string[] { + const declaredCssNames = [ + ...deps.themeOptions + .getThemes() + .map(([theme, size, color, mode]) => + generateBundleName(theme, size, color, mode).replace(/\.scss$/, '.css'), + ), + 'dx.common.css', + ]; + + return declaredCssNames.filter((name) => !fs.existsSync(path.join(cssOutputDir, name))); +} + function loadDependencies(projectRoot: string): BuildDependencies { const projectRequire = createRequire(path.join(projectRoot, 'package.json')); @@ -207,6 +223,32 @@ async function compileFile( await writeFileText(path.join(outputDir, outFileName), withHeader); } +async function compileAccentOverrides( + projectRoot: string, + cssOutputDir: string, + deps: BuildDependencies, +): Promise { + const accentSourcesDir = path.resolve(projectRoot, ACCENT_SOURCES_DIR); + const pattern = normalizeGlobPathForWindows(path.join(accentSourcesDir, '*.scss')); + const accentSources = await glob(pattern, { nodir: true }); + + if (accentSources.length === 0) { + throw new Error(`No accent palettes to compile in ${accentSourcesDir}`); + } + + const accentOutputDir = path.join(cssOutputDir, ACCENT_OUTPUT_DIR_NAME); + + for (const source of accentSources) { + logger.verbose(`Compiling accent ${source}`); + const compiled = deps.sass.compile(source); + const outFileName = `${path.basename(source, '.scss')}.css`; + const license = createStarLicenseHeader(outFileName, deps.devextremeVersion); + const css = compiled.css.replace(GENERATOR_BANNER_REGEX, ''); + const withHeader = prependLicenseAndMoveCharsetFirst(css, license); + await writeFileText(path.join(accentOutputDir, outFileName), withHeader); + } +} + async function copyThemeAssets(projectRoot: string, cssOutputDir: string): Promise { const fontsFrom = path.resolve(projectRoot, 'fonts'); const iconsFrom = path.resolve(projectRoot, 'icons'); @@ -280,6 +322,15 @@ async function runSingleBuild( logger.verbose(`Compiling ${source}`); await compileFile(source, cssOutputDir, minifyProfile, deps, projectRoot); } + + await compileAccentOverrides(projectRoot, cssOutputDir, deps); + + if (options.mode !== 'ci') { + const missingThemeCss = findMissingThemeCss(cssOutputDir, deps); + if (missingThemeCss.length > 0) { + throw new Error(`Declared themes produced no CSS: ${missingThemeCss.join(', ')}`); + } + } } function loadChokidar(projectRoot: string): { @@ -319,6 +370,7 @@ async function runWatchBuild( await compileFile(source, cssOutputDir, minifyProfile, deps, projectRoot); } + await compileAccentOverrides(projectRoot, cssOutputDir, deps); await copyThemeAssets(projectRoot, cssOutputDir); }; From c3b837fd4a55f7bad6d386bf2ba4e3796b4c3dc4 Mon Sep 17 00:00:00 2001 From: Raushen Date: Mon, 17 Aug 2026 18:36:39 +0300 Subject: [PATCH 2/5] Keep internal sources out of ThemeBuilder, share one exclude list --- .../build/internal-scss-paths.json | 5 +++ .../src/metadata/collector.ts | 13 +++++-- .../_design-system/fluent/accents/blue.scss | 3 ++ .../scss/_design-system/variables/_ds.scss | 1 + .../bundles/dx.fluent-next.blue.light.scss | 1 + .../scss/widgets/fluent-next/_colors.scss | 3 ++ .../tests/metadata/collector.test.ts | 39 +++++++++++++++---- packages/devextreme/project.json | 10 ++--- .../scss-assemble/executor.e2e.spec.ts | 34 ++++++++++++---- .../src/executors/scss-assemble/schema.json | 7 ---- .../src/executors/scss-assemble/schema.ts | 1 - .../scss-assemble/scss-assemble.impl.ts | 31 +++++++++++---- 12 files changed, 108 insertions(+), 40 deletions(-) create mode 100644 packages/devextreme-scss/build/internal-scss-paths.json create mode 100644 packages/devextreme-themebuilder/tests/data/scss/_design-system/fluent/accents/blue.scss create mode 100644 packages/devextreme-themebuilder/tests/data/scss/_design-system/variables/_ds.scss create mode 100644 packages/devextreme-themebuilder/tests/data/scss/bundles/dx.fluent-next.blue.light.scss create mode 100644 packages/devextreme-themebuilder/tests/data/scss/widgets/fluent-next/_colors.scss diff --git a/packages/devextreme-scss/build/internal-scss-paths.json b/packages/devextreme-scss/build/internal-scss-paths.json new file mode 100644 index 000000000000..79b32f49e75f --- /dev/null +++ b/packages/devextreme-scss/build/internal-scss-paths.json @@ -0,0 +1,5 @@ +[ + "widgets/fluent-next/", + "_design-system/", + "bundles/dx.fluent-next." +] diff --git a/packages/devextreme-themebuilder/src/metadata/collector.ts b/packages/devextreme-themebuilder/src/metadata/collector.ts index a34f36346a47..4301b7a680ac 100644 --- a/packages/devextreme-themebuilder/src/metadata/collector.ts +++ b/packages/devextreme-themebuilder/src/metadata/collector.ts @@ -1,13 +1,21 @@ import { promises as fs } from 'fs'; import { - resolve, relative, join, dirname, + resolve, relative, join, dirname, sep, } from 'path'; import MetadataGenerator from './generator'; +import internalScssPaths from '../../../devextreme-scss/build/internal-scss-paths.json'; + +const isInternalScssPath = (relativePath: string): boolean => { + const posixPath = relativePath.split(sep).join('/'); + return internalScssPaths.some((internalPath) => posixPath.startsWith(internalPath)); +}; export default class MetadataCollector { generator = new MetadataGenerator(); static async saveScssFiles(files: Promise, destination: string): Promise { + await fs.rm(resolve(destination), { recursive: true, force: true }); + await Promise.all((await files).map(async (file) => { const absolutePath = resolve(join(destination, file.path)); const directory = dirname(absolutePath); @@ -36,8 +44,7 @@ export default class MetadataCollector { handler: (content: string) => string, ): Promise { const fileList = (await this.getFileList(dirName)) - // fluent-next is an internal design-tokens theme, not exposed in ThemeBuilder - .filter((filePath) => !filePath.includes('fluent-next')); + .filter((filePath) => !isInternalScssPath(relative(dirName, filePath))); return Promise.all(fileList.map(async (filePath) => { const relativePath = relative(dirName, filePath); diff --git a/packages/devextreme-themebuilder/tests/data/scss/_design-system/fluent/accents/blue.scss b/packages/devextreme-themebuilder/tests/data/scss/_design-system/fluent/accents/blue.scss new file mode 100644 index 000000000000..b7fb0c97dace --- /dev/null +++ b/packages/devextreme-themebuilder/tests/data/scss/_design-system/fluent/accents/blue.scss @@ -0,0 +1,3 @@ +:root { + --dxds-primary-100: #0f6cbd; +} diff --git a/packages/devextreme-themebuilder/tests/data/scss/_design-system/variables/_ds.scss b/packages/devextreme-themebuilder/tests/data/scss/_design-system/variables/_ds.scss new file mode 100644 index 000000000000..09edaffa3110 --- /dev/null +++ b/packages/devextreme-themebuilder/tests/data/scss/_design-system/variables/_ds.scss @@ -0,0 +1 @@ +$primary-100: var(--dxds-primary-100); diff --git a/packages/devextreme-themebuilder/tests/data/scss/bundles/dx.fluent-next.blue.light.scss b/packages/devextreme-themebuilder/tests/data/scss/bundles/dx.fluent-next.blue.light.scss new file mode 100644 index 000000000000..7e67cd53ee5f --- /dev/null +++ b/packages/devextreme-themebuilder/tests/data/scss/bundles/dx.fluent-next.blue.light.scss @@ -0,0 +1 @@ +@use "../widgets/fluent-next/colors" with ($color: "blue", $mode: "light"); diff --git a/packages/devextreme-themebuilder/tests/data/scss/widgets/fluent-next/_colors.scss b/packages/devextreme-themebuilder/tests/data/scss/widgets/fluent-next/_colors.scss new file mode 100644 index 000000000000..feece888aa44 --- /dev/null +++ b/packages/devextreme-themebuilder/tests/data/scss/widgets/fluent-next/_colors.scss @@ -0,0 +1,3 @@ +@use "../../_design-system/variables/ds" as ds; + +$base-accent: ds.$primary-100; diff --git a/packages/devextreme-themebuilder/tests/metadata/collector.test.ts b/packages/devextreme-themebuilder/tests/metadata/collector.test.ts index 1334e81f9621..a213745ecb74 100644 --- a/packages/devextreme-themebuilder/tests/metadata/collector.test.ts +++ b/packages/devextreme-themebuilder/tests/metadata/collector.test.ts @@ -1,4 +1,6 @@ -import { join, resolve, dirname } from 'path'; +import { + join, resolve, dirname, relative, +} from 'path'; import { promises } from 'fs'; import MetadataCollector from '../../src/metadata/collector'; @@ -7,7 +9,7 @@ const rootDir = join(__dirname, '..', '..'); const scssDir = join(rootDir, 'tests', 'data', 'scss'); describe('MetadataCollector', () => { - const expectedFileList: string[] = [ + const exposedFileList: string[] = [ join('bundles', 'dx.light.scss'), join('bundles', 'dx.material.blue.light.scss'), join('widgets', 'generic', 'accordion', '_colors.scss'), @@ -21,8 +23,16 @@ describe('MetadataCollector', () => { join('widgets', 'material', '_index.scss'), ]; + const internalFileList: string[] = [ + join('_design-system', 'fluent', 'accents', 'blue.scss'), + join('_design-system', 'variables', '_ds.scss'), + join('widgets', 'fluent-next', '_colors.scss'), + join('bundles', 'dx.fluent-next.blue.light.scss'), + ]; + promises.mkdir = jest.fn(); promises.writeFile = jest.fn(); + promises.rm = jest.fn(); beforeEach(() => { jest.clearAllMocks(); @@ -31,22 +41,26 @@ describe('MetadataCollector', () => { test('getFileList', async () => { const collector = new MetadataCollector(); const fileList = await collector.getFileList(join(rootDir, 'tests', 'data', 'scss')); - const expectedFullPaths = expectedFileList.map((file) => join(scssDir, file)); + const expectedFullPaths = [...exposedFileList, ...internalFileList] + .map((file) => join(scssDir, file)); expect(fileList.length).toBe(expectedFullPaths.length); fileList.forEach((file) => expect(expectedFullPaths).toContain(file)); }); - test('readFiles', async () => { + test('readFiles keeps out the paths the scss package marks as internal', async () => { const collector = new MetadataCollector(); const handler = (content: string): string => content; - const filesInfo = await collector.readFiles(join(rootDir, 'tests', 'data', 'scss'), handler); + const onDisk = (await collector.getFileList(scssDir)).map((file) => relative(scssDir, file)); + const filesInfo = await collector.readFiles(scssDir, handler); + const returnedPaths = filesInfo.map((file) => file.path); - expect(filesInfo.length).toBe(expectedFileList.length); + expect(onDisk.filter((file) => !returnedPaths.includes(file)).sort()) + .toEqual([...internalFileList].sort()); + expect(returnedPaths.sort()).toEqual([...exposedFileList].sort()); filesInfo.forEach((file) => { - expect(expectedFileList).toContain(file.path); expect(typeof file.content).toBe('string'); expect(file.content.length).toBeGreaterThan(0); }); @@ -79,6 +93,17 @@ describe('MetadataCollector', () => { expect(promises.writeFile).toHaveBeenCalledWith(expectedDestinationPath, fileContent); }); + test('saveScssFiles wipes the destination so sources deleted upstream stop shipping', async () => { + const destinationPath = './scss'; + + await MetadataCollector.saveScssFiles(Promise.resolve([]), destinationPath); + + expect(promises.rm).toHaveBeenCalledWith( + resolve(destinationPath), + { recursive: true, force: true }, + ); + }); + test('saveMetadata', async () => { const collector = new MetadataCollector(); const version = '1.1.1'; diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 6cc1354d93d4..a3c1dd84dacb 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1226,12 +1226,7 @@ "executor": "devextreme-nx-infra-plugin:scss-assemble", "options": { "scssPackagePath": "../devextreme-scss", - "outputDir": "./artifacts/npm/devextreme/scss", - "exclude": [ - "widgets/fluent-next/**", - "_design-system/**", - "bundles/dx.fluent-next.*.scss" - ] + "outputDir": "./artifacts/npm/devextreme/scss" }, "configurations": { "internal": { @@ -1247,7 +1242,8 @@ "inputs": [ "{workspaceRoot}/packages/devextreme-scss/scss/**/*", "{workspaceRoot}/packages/devextreme-scss/fonts/**/*", - "{workspaceRoot}/packages/devextreme-scss/icons/**/*" + "{workspaceRoot}/packages/devextreme-scss/icons/**/*", + "{workspaceRoot}/packages/devextreme-scss/build/internal-scss-paths.json" ], "outputs": [ "{projectRoot}/artifacts/npm/devextreme/scss/**/*", diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts index cb2b69e7bb26..27b6df9f780f 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts @@ -34,6 +34,11 @@ describe('ScssAssembleExecutor E2E', () => { fs.mkdirSync(path.join(scssPackageDir, 'scss'), { recursive: true }); fs.mkdirSync(path.join(scssPackageDir, 'fonts'), { recursive: true }); fs.mkdirSync(path.join(scssPackageDir, 'icons', 'material'), { recursive: true }); + fs.mkdirSync(path.join(scssPackageDir, 'build'), { recursive: true }); + fs.writeFileSync( + path.join(scssPackageDir, 'build', 'internal-scss-paths.json'), + JSON.stringify(['widgets/dropped/', 'bundles/dx.dropped.']), + ); }); afterEach(() => { @@ -84,28 +89,41 @@ describe('ScssAssembleExecutor E2E', () => { expect(content).toContain(expectedPng); }); - it('should keep excluded sources out of the package', async () => { + it('should keep the paths listed as internal by the scss package out of the package', async () => { await writeFileText( path.join(scssPackageDir, 'scss', 'widgets', 'kept', '_index.scss'), '.a {}', ); await writeFileText( - path.join(scssPackageDir, 'scss', 'widgets', 'dropped', '_index.scss'), + path.join(scssPackageDir, 'scss', 'widgets', 'dropped', 'nested', '_index.scss'), '.b {}', ); await writeFileText(path.join(scssPackageDir, 'scss', 'bundles', 'dx.kept.scss'), '.c {}'); - await writeFileText(path.join(scssPackageDir, 'scss', 'bundles', 'dx.dropped.scss'), '.d {}'); + await writeFileText( + path.join(scssPackageDir, 'scss', 'bundles', 'dx.dropped.blue.light.scss'), + '.d {}', + ); const context = createMockContext({ root: tempDir }); - const result = await executor( - { ...OPTIONS, exclude: ['widgets/dropped/**', 'bundles/dx.dropped.scss'] }, - context, - ); + const result = await executor(OPTIONS, context); expect(result.success).toBe(true); expect(fs.existsSync(path.join(outputDir, 'widgets', 'kept', '_index.scss'))).toBe(true); expect(fs.existsSync(path.join(outputDir, 'bundles', 'dx.kept.scss'))).toBe(true); expect(fs.existsSync(path.join(outputDir, 'widgets', 'dropped'))).toBe(false); - expect(fs.existsSync(path.join(outputDir, 'bundles', 'dx.dropped.scss'))).toBe(false); + expect(fs.existsSync(path.join(outputDir, 'bundles', 'dx.dropped.blue.light.scss'))).toBe( + false, + ); + }); + + it('should fail instead of publishing everything when the internal path list is missing', async () => { + await writeFileText(path.join(scssPackageDir, 'scss', 'placeholder.scss'), '.a {}'); + fs.rmSync(path.join(scssPackageDir, 'build', 'internal-scss-paths.json')); + + const context = createMockContext({ root: tempDir }); + const result = await executor(OPTIONS, context); + + expect(result.success).toBe(false); + expect(fs.existsSync(path.join(outputDir, 'placeholder.scss'))).toBe(false); }); }); diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json index 042520c8ebd4..3efa0803ccff 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json @@ -11,13 +11,6 @@ "outputDir": { "type": "string", "description": "Output directory for assembled SCSS files (relative to project root)." - }, - "exclude": { - "type": "array", - "description": "Glob patterns, relative to the scss directory, kept out of the package.", - "items": { - "type": "string" - } } }, "required": ["scssPackagePath", "outputDir"] diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts index 859d836d2d4c..196986ac5966 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts @@ -1,5 +1,4 @@ export interface ScssAssembleExecutorSchema { scssPackagePath: string; outputDir: string; - exclude?: string[]; } diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts index 175d296951b7..f926aa5c9e49 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 @@ -4,12 +4,28 @@ import { glob } from 'glob'; import { logger } from '@nx/devkit'; import { createExecutor } from '../../utils/create-executor'; import { toPosixPath } from '../../utils/path-resolver'; -import { readFileText, writeFileText, ensureDir } from '../../utils/file-operations'; +import { readFileText, writeFileText, ensureDir, exists } 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 SCSS_EXTENSIONS = new Set(['.scss', '.css']); +const INTERNAL_SCSS_PATHS_FILE = path.join('build', 'internal-scss-paths.json'); + +export async function readInternalScssPaths(scssPackagePath: string): Promise { + const listPath = path.join(scssPackagePath, INTERNAL_SCSS_PATHS_FILE); + + if (!(await exists(listPath))) { + throw new Error(`Internal SCSS path list not found: ${listPath}`); + } + + return JSON.parse(await readFileText(listPath)) as string[]; +} + +export function isInternalScssPath(relativePath: string, internalPaths: string[]): boolean { + const posixPath = relativePath.split('\\').join('/'); + return internalPaths.some((internalPath) => posixPath.startsWith(internalPath)); +} async function inlineDataUri(content: string, scssRoot: string): Promise { const matches = [...content.matchAll(DATA_URI_SCSS_REGEX)]; @@ -37,11 +53,13 @@ async function inlineDataUri(content: string, scssRoot: string): Promise async function copyScssWithInlineDataUri( scssPackagePath: string, outputDir: string, - exclude: string[], ): Promise { const scssSourceDir = path.join(scssPackagePath, 'scss'); const cwd = toPosixPath(scssSourceDir); - const relPaths = await glob('**/*', { cwd, nodir: true, ignore: exclude }); + const internalPaths = await readInternalScssPaths(scssPackagePath); + const relPaths = (await glob('**/*', { cwd, nodir: true })).filter( + (relPath) => !isInternalScssPath(relPath, internalPaths), + ); await Promise.all( relPaths.map(async (relPath) => { @@ -78,7 +96,6 @@ async function copyIcons(scssPackagePath: string, outputDir: string): Promise({ @@ -86,11 +103,11 @@ export default createExecutor( resolve: (options, { projectRoot }) => { const scssPackagePath = path.resolve(projectRoot, options.scssPackagePath); const outputDir = path.resolve(projectRoot, options.outputDir); - return { scssPackagePath, outputDir, exclude: options.exclude ?? [] }; + return { scssPackagePath, outputDir }; }, - run: async ({ scssPackagePath, outputDir, exclude }) => { + run: async ({ scssPackagePath, outputDir }) => { await Promise.all([ - copyScssWithInlineDataUri(scssPackagePath, outputDir, exclude), + copyScssWithInlineDataUri(scssPackagePath, outputDir), copyFonts(scssPackagePath, outputDir), copyIcons(scssPackagePath, outputDir), ]); From ff8091963488917c75ba3e1ce7b687ad1e718e78 Mon Sep 17 00:00:00 2001 From: Raushen Date: Wed, 19 Aug 2026 14:38:14 +0300 Subject: [PATCH 3/5] Wait for the bundles the scss package ships --- packages/devextreme/project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index a3c1dd84dacb..381590c2eb2b 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1236,7 +1236,7 @@ "dependsOn": [ { "projects": ["devextreme-scss"], - "target": "build:tokens" + "target": "build:themes" } ], "inputs": [ From 0b1b9cb23cc578e161bc1585bf60d63ba61ef186 Mon Sep 17 00:00:00 2001 From: Raushen Date: Wed, 19 Aug 2026 15:22:25 +0300 Subject: [PATCH 4/5] Strip the leading comment only when it is the generator banner --- .../executors/scss-build/executor.e2e.spec.ts | 27 ++++++++++++++++++- .../executors/scss-build/scss-build.impl.ts | 8 ++++-- 2 files changed, 32 insertions(+), 3 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 84f7f0969c7a..3012a34283a6 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 @@ -29,7 +29,9 @@ function createMockModules(projectRoot: string): void { '}', 'module.exports = {', ' SassString,', - ' compile: () => ({ css: \'/**\\n * Do not edit directly, this file was auto-generated.\\n */\\n@charset "UTF-8"; .a{display:flex}\' })', + " compile: (source) => ({ css: source.includes('unbannered')", + " ? '/* not the banner */\\n:root{--first-declaration:1}\\n/* mentions auto-generated */\\n:root{--last-declaration:2}'", + ' : \'/**\\n * Do not edit directly, this file was auto-generated.\\n */\\n@charset "UTF-8"; .a{display:flex}\' })', '};', '', ].join('\n'), @@ -221,6 +223,29 @@ describe('ScssBuildExecutor E2E', () => { expect(stormCss).not.toContain('auto-generated'); }); + it('strips only a leading generator banner, keeping css that merely mentions auto-generated', async () => { + const projectRoot = await setupProjectStructure(tempDir); + await writeFileText( + path.join(projectRoot, 'scss', '_design-system', 'fluent', 'accents', 'unbannered.scss'), + ':root { --dxds-primary-100: #6d6a68; }', + ); + + const context = createMockContext({ + root: tempDir, + projectName: 'devextreme-scss', + projectRoot: 'packages/devextreme-scss', + }); + + await executor({ mode: 'all', cssOutputDir: './artifacts/css' }, context); + + const unbanneredCss = await readFileText( + path.join(projectRoot, 'artifacts', 'css', 'accents', 'unbannered.css'), + ); + expect(unbanneredCss).toContain('/* not the banner */'); + expect(unbanneredCss).toContain('--first-declaration'); + expect(unbanneredCss).toContain('--last-declaration'); + }); + it('builds ci mode only for selected dev bundles and uses ci profile', async () => { const projectRoot = await setupProjectStructure(tempDir); const context = createMockContext({ 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 2ee59affd76d..ee447cf0532b 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 @@ -15,7 +15,8 @@ const DEFAULT_BUNDLES_DIR = './scss/bundles'; const DEFAULT_CSS_OUTPUT_DIR = '../devextreme/artifacts/css'; const ACCENT_SOURCES_DIR = './scss/_design-system/fluent/accents'; const ACCENT_OUTPUT_DIR_NAME = 'accents'; -const GENERATOR_BANNER_REGEX = /^\s*\/\*[\s\S]*?auto-generated[\s\S]*?\*\/\s*/; +const LEADING_COMMENT_REGEX = /^\s*\/\*[\s\S]*?\*\/\s*/; +const GENERATOR_BANNER_MARKER = 'auto-generated'; const DEFAULT_DEV_BUNDLE_NAMES = [ 'light', 'light.compact', @@ -243,7 +244,10 @@ async function compileAccentOverrides( const compiled = deps.sass.compile(source); const outFileName = `${path.basename(source, '.scss')}.css`; const license = createStarLicenseHeader(outFileName, deps.devextremeVersion); - const css = compiled.css.replace(GENERATOR_BANNER_REGEX, ''); + const leadingComment = LEADING_COMMENT_REGEX.exec(compiled.css)?.[0] ?? ''; + const css = leadingComment.includes(GENERATOR_BANNER_MARKER) + ? compiled.css.slice(leadingComment.length) + : compiled.css; const withHeader = prependLicenseAndMoveCharsetFirst(css, license); await writeFileText(path.join(accentOutputDir, outFileName), withHeader); } From 6f5873c515030d0224a211d439e1978ded035f75 Mon Sep 17 00:00:00 2001 From: Raushen Date: Wed, 19 Aug 2026 15:40:47 +0300 Subject: [PATCH 5/5] Assemble the scss package into a clean destination --- .../scss-assemble/executor.e2e.spec.ts | 21 +++++++++++++++++++ .../scss-assemble/scss-assemble.impl.ts | 8 +++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts index 27b6df9f780f..aa2fea1f6081 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts @@ -117,6 +117,7 @@ describe('ScssAssembleExecutor E2E', () => { }); it('should fail instead of publishing everything when the internal path list is missing', async () => { + await writeFileText(path.join(outputDir, 'widgets', 'kept', '_index.scss'), '.a {}'); await writeFileText(path.join(scssPackageDir, 'scss', 'placeholder.scss'), '.a {}'); fs.rmSync(path.join(scssPackageDir, 'build', 'internal-scss-paths.json')); @@ -125,5 +126,25 @@ describe('ScssAssembleExecutor E2E', () => { expect(result.success).toBe(false); expect(fs.existsSync(path.join(outputDir, 'placeholder.scss'))).toBe(false); + expect(await readFileText(path.join(outputDir, 'widgets', 'kept', '_index.scss'))).toBe( + '.a {}', + ); + }); + + it('should drop files an earlier run left in the output directory', async () => { + await writeFileText(path.join(outputDir, 'widgets', 'renamed-away', '_index.scss'), '.old {}'); + await writeFileText( + path.join(scssPackageDir, 'scss', 'widgets', 'kept', '_index.scss'), + '.new {}', + ); + + const context = createMockContext({ root: tempDir }); + const result = await executor(OPTIONS, context); + + expect(result.success).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'widgets', 'renamed-away'))).toBe(false); + expect(await readFileText(path.join(outputDir, 'widgets', 'kept', '_index.scss'))).toBe( + '.new {}', + ); }); }); 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 f926aa5c9e49..786867196d63 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 @@ -53,10 +53,10 @@ async function inlineDataUri(content: string, scssRoot: string): Promise async function copyScssWithInlineDataUri( scssPackagePath: string, outputDir: string, + internalPaths: string[], ): Promise { const scssSourceDir = path.join(scssPackagePath, 'scss'); const cwd = toPosixPath(scssSourceDir); - const internalPaths = await readInternalScssPaths(scssPackagePath); const relPaths = (await glob('**/*', { cwd, nodir: true })).filter( (relPath) => !isInternalScssPath(relPath, internalPaths), ); @@ -106,8 +106,12 @@ export default createExecutor( return { scssPackagePath, outputDir }; }, run: async ({ scssPackagePath, outputDir }) => { + const internalPaths = await readInternalScssPaths(scssPackagePath); + + await fs.rm(outputDir, { recursive: true, force: true }); + await Promise.all([ - copyScssWithInlineDataUri(scssPackagePath, outputDir), + copyScssWithInlineDataUri(scssPackagePath, outputDir, internalPaths), copyFonts(scssPackagePath, outputDir), copyIcons(scssPackagePath, outputDir), ]);