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-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-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 19efa18de2d0..381590c2eb2b 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1233,10 +1233,17 @@ "outputDir": "./artifacts/npm/devextreme-internal/scss" } }, + "dependsOn": [ + { + "projects": ["devextreme-scss"], + "target": "build:themes" + } + ], "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 bebf5fbea714..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 @@ -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(() => { @@ -83,4 +88,63 @@ describe('ScssAssembleExecutor E2E', () => { expect(content).toContain(expectedSvg); expect(content).toContain(expectedPng); }); + + 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', 'nested', '_index.scss'), + '.b {}', + ); + await writeFileText(path.join(scssPackageDir, 'scss', 'bundles', 'dx.kept.scss'), '.c {}'); + await writeFileText( + path.join(scssPackageDir, 'scss', 'bundles', 'dx.dropped.blue.light.scss'), + '.d {}', + ); + + const context = createMockContext({ root: tempDir }); + 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.blue.light.scss'))).toBe( + false, + ); + }); + + 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')); + + 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); + 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 b49778435343..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 @@ -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,10 +53,13 @@ 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 relPaths = await glob('**/*', { cwd, nodir: true }); + const relPaths = (await glob('**/*', { cwd, nodir: true })).filter( + (relPath) => !isInternalScssPath(relPath, internalPaths), + ); await Promise.all( relPaths.map(async (relPath) => { @@ -87,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), ]); 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..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 @@ -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,9 @@ function createMockModules(projectRoot: string): void { '}', 'module.exports = {', ' SassString,', - ' compile: () => ({ css: \'@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'), @@ -144,6 +146,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 +196,56 @@ 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('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({ @@ -219,6 +276,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..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 @@ -13,6 +13,10 @@ 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 LEADING_COMMENT_REGEX = /^\s*\/\*[\s\S]*?\*\/\s*/; +const GENERATOR_BANNER_MARKER = 'auto-generated'; const DEFAULT_DEV_BUNDLE_NAMES = [ 'light', 'light.compact', @@ -112,6 +116,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 +224,35 @@ 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 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); + } +} + async function copyThemeAssets(projectRoot: string, cssOutputDir: string): Promise { const fontsFrom = path.resolve(projectRoot, 'fonts'); const iconsFrom = path.resolve(projectRoot, 'icons'); @@ -280,6 +326,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 +374,7 @@ async function runWatchBuild( await compileFile(source, cssOutputDir, minifyProfile, deps, projectRoot); } + await compileAccentOverrides(projectRoot, cssOutputDir, deps); await copyThemeAssets(projectRoot, cssOutputDir); };