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 3186b5f06bef..71ff73285689 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-scss/scss/widgets/fluent-next/accents/custom.css b/packages/devextreme-scss/scss/widgets/fluent-next/accents/custom.css new file mode 100644 index 000000000000..8c2d8ad7c807 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/fluent-next/accents/custom.css @@ -0,0 +1,32 @@ +/* + * Accent scale derived from a single brand color: set --dx-accent-color and include this file + * instead of a palette from the same folder. The given color becomes step 100 as is; lighter + * steps move toward --dx-accent-lightness-max, darker ones toward --dx-accent-lightness-min, + * both fading chroma to --dx-accent-chroma-min. The hue never changes. + * + * The scale is an approximation, not a replacement for the designed palettes: on the shipped + * eleven it differs by up to 0.06 dE in oklab, mostly on the lightest steps. + */ +:root { + --dx-accent-lightness-max: 0.95; + --dx-accent-lightness-min: 0.15; + --dx-accent-chroma-min: 0.04; + --dxds-primary-10: oklch(from var(--dx-accent-color) calc(l + 9 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 9 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-20: oklch(from var(--dx-accent-color) calc(l + 8 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 8 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-30: oklch(from var(--dx-accent-color) calc(l + 7 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 7 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-40: oklch(from var(--dx-accent-color) calc(l + 6 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 6 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-50: oklch(from var(--dx-accent-color) calc(l + 5 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 5 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-60: oklch(from var(--dx-accent-color) calc(l + 4 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 4 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-70: oklch(from var(--dx-accent-color) calc(l + 3 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 3 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-80: oklch(from var(--dx-accent-color) calc(l + 2 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 2 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-90: oklch(from var(--dx-accent-color) calc(l + 1 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 1 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-100: oklch(from var(--dx-accent-color) l c h); + --dxds-primary-110: oklch(from var(--dx-accent-color) calc(l - 1 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 1 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-120: oklch(from var(--dx-accent-color) calc(l - 2 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 2 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-130: oklch(from var(--dx-accent-color) calc(l - 3 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 3 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-140: oklch(from var(--dx-accent-color) calc(l - 4 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 4 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-150: oklch(from var(--dx-accent-color) calc(l - 5 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 5 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-160: oklch(from var(--dx-accent-color) calc(l - 6 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 6 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-170: oklch(from var(--dx-accent-color) calc(l - 7 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 7 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-180: oklch(from var(--dx-accent-color) calc(l - 8 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 8 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); +} diff --git a/packages/devextreme-scss/tests/accent-custom.test.ts b/packages/devextreme-scss/tests/accent-custom.test.ts new file mode 100644 index 000000000000..961d6e05fe64 --- /dev/null +++ b/packages/devextreme-scss/tests/accent-custom.test.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const scssRoot = path.resolve(__dirname, '..', 'scss'); +const designedAccentsDir = path.join(scssRoot, '_design-system', 'fluent', 'accents'); +const customAccentPath = path.join(scssRoot, 'widgets', 'fluent-next', 'accents', 'custom.css'); + +const readSteps = (filePath: string): string[] => { + const content = fs.readFileSync(filePath, 'utf8'); + return [...content.matchAll(/--dxds-primary-(\d+)\s*:/g)].map((match) => match[1]); +}; + +const designedPalettes = fs.readdirSync(designedAccentsDir) + .filter((name) => name.endsWith('.scss')) + .map((name) => ({ name, steps: readSteps(path.join(designedAccentsDir, name)) })); + +describe('accents/custom.css', () => { + it('covers exactly the steps the designed palettes declare', () => { + const customAccentSteps = readSteps(customAccentPath); + + expect(designedPalettes.length).toBeGreaterThan(0); + designedPalettes.forEach(({ name, steps }) => { + expect([name, customAccentSteps]).toEqual([name, steps]); + }); + }); + + it('derives every step from --dx-accent-color instead of a literal color', () => { + const declarations = fs.readFileSync(customAccentPath, 'utf8') + .match(/--dxds-primary-\d+\s*:[^;]+;/g) ?? []; + + expect(declarations).toHaveLength(readSteps(customAccentPath).length); + declarations.forEach((declaration) => { + expect(declaration).toContain('oklch(from var(--dx-accent-color)'); + expect(declaration).not.toMatch(/#[0-9a-f]{3,8}\b/i); + }); + }); +}); diff --git a/packages/devextreme-scss/tools/naming/derive-registries.mjs b/packages/devextreme-scss/tools/naming/derive-registries.mjs index e3bda97f02d7..65c8a03347c8 100644 --- a/packages/devextreme-scss/tools/naming/derive-registries.mjs +++ b/packages/devextreme-scss/tools/naming/derive-registries.mjs @@ -48,8 +48,12 @@ const OVERRIDES = { * `typography` is a registered concern — the common/ criterion, satisfied without moving anything. * Moving the declarations into common/ was tried and reverted: it pulled common/ earlier in the * load order and shifted `--dx-line-height` inside its :root block, i.e. it changed the emitted CSS. + * + * `accents/` holds no SCSS at all: it is the hand-written companion of the generated palettes in + * `_design-system/fluent/accents/`, shipped as a standalone stylesheet next to them in + * `dist/css/accents/`. It declares design-system custom properties, never theme variables. */ - systemFolders: ['common', 'typography'], + systemFolders: ['accents', 'common', 'typography'], // component -> folder that is allowed to declare it (O2: exactly one declaration home). // Only needed where more than one folder currently declares the component's variables. diff --git a/packages/devextreme-scss/tools/naming/registries.json b/packages/devextreme-scss/tools/naming/registries.json index ef2ec7109bd9..aaddfc821791 100644 --- a/packages/devextreme-scss/tools/naming/registries.json +++ b/packages/devextreme-scss/tools/naming/registries.json @@ -4,7 +4,7 @@ "derivedFrom": { "componentTokens": "@devexpress/design-tokens-internal → components/core/theme/fluent", "componentTokenCount": 601, - "themeFolders": 86 + "themeFolders": 87 }, "components": { "accordion": "accordion", @@ -178,6 +178,7 @@ "widget": "widget" }, "systemFolders": [ + "accents", "common", "typography" ], 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..e1c16507d5c7 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,42 @@ 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; }', + ); + await writeFileText( + path.join(projectRoot, 'scss', 'widgets', 'fluent-next', 'accents', 'custom.css'), + ':root { --dxds-primary-100: oklch(from var(--dx-accent-color) l c h); }', + ); + + 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'); + + const customAccentCss = await readFileText( + path.join(projectRoot, 'artifacts', 'css', 'accents', 'custom.css'), + ); + expect(customAccentCss).toContain('DevExtreme (custom.css)'); + }); + it('builds ci mode only for selected dev bundles and uses ci profile', async () => { const projectRoot = await setupProjectStructure(tempDir); const context = createMockContext({ @@ -219,6 +260,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..468638dfd9d2 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 GENERATED_ACCENT_PALETTES_DIR = './scss/_design-system/fluent/accents'; +const AUTHORED_ACCENT_STYLES_DIR = './scss/widgets/fluent-next/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 +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,37 @@ async function compileFile( await writeFileText(path.join(outputDir, outFileName), withHeader); } +function globAccentSources(sourcesDir: string, extension: string): Promise { + return glob(normalizeGlobPathForWindows(path.join(sourcesDir, extension)), { nodir: true }); +} + +async function compileAccentOverrides( + projectRoot: string, + cssOutputDir: string, + deps: BuildDependencies, +): Promise { + const palettesDir = path.resolve(projectRoot, GENERATED_ACCENT_PALETTES_DIR); + const palettes = await globAccentSources(palettesDir, '*.scss'); + + if (palettes.length === 0) { + throw new Error(`No accent palettes to compile in ${palettesDir}`); + } + + const authoredDir = path.resolve(projectRoot, AUTHORED_ACCENT_STYLES_DIR); + const accentSources = [...palettes, ...(await globAccentSources(authoredDir, '*.css'))]; + 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, path.extname(source))}.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 +328,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 +376,7 @@ async function runWatchBuild( await compileFile(source, cssOutputDir, minifyProfile, deps, projectRoot); } + await compileAccentOverrides(projectRoot, cssOutputDir, deps); await copyThemeAssets(projectRoot, cssOutputDir); };