From 568231ff0b59581daa47933d7860c3b6bfb0cf3a Mon Sep 17 00:00:00 2001 From: Andrei Vorobev Date: Mon, 13 Jul 2026 17:35:25 +0300 Subject: [PATCH 1/4] Gulp: localization generate-community-locales gulp task move to nx executor --- .../devextreme/build/gulp/localization.js | 44 +------ packages/devextreme/package.json | 2 +- packages/devextreme/project.json | 8 ++ packages/nx-infra-plugin/AGENTS.md | 1 + packages/nx-infra-plugin/executors.json | 5 + .../executor.e2e.spec.ts | 111 ++++++++++++++++++ .../generate-community-locales/executor.ts | 1 + .../generate-community-locales.impl.ts | 105 +++++++++++++++++ .../generate-community-locales/schema.json | 18 +++ .../generate-community-locales/schema.ts | 4 + 10 files changed, 255 insertions(+), 44 deletions(-) create mode 100644 packages/nx-infra-plugin/src/executors/generate-community-locales/executor.e2e.spec.ts create mode 100644 packages/nx-infra-plugin/src/executors/generate-community-locales/executor.ts create mode 100644 packages/nx-infra-plugin/src/executors/generate-community-locales/generate-community-locales.impl.ts create mode 100644 packages/nx-infra-plugin/src/executors/generate-community-locales/schema.json create mode 100644 packages/nx-infra-plugin/src/executors/generate-community-locales/schema.ts diff --git a/packages/devextreme/build/gulp/localization.js b/packages/devextreme/build/gulp/localization.js index ecf67b940fc5..3f43b1c65bf7 100644 --- a/packages/devextreme/build/gulp/localization.js +++ b/packages/devextreme/build/gulp/localization.js @@ -1,50 +1,8 @@ 'use strict'; const gulp = require('gulp'); -const path = require('path'); const shell = require('gulp-shell'); -const through = require('through2'); -const fs = require('fs'); - -const DEFAULT_LOCALE = 'en'; -const DICTIONARY_SOURCE_FOLDER = 'js/localization/messages'; gulp.task('localization', shell.task('pnpm nx build:localization devextreme')); -gulp.task('generate-community-locales', () => { - const defaultFile = fs.readFileSync(path.join(DICTIONARY_SOURCE_FOLDER, DEFAULT_LOCALE + '.json')).toString(); - const defaultDictionaryKeys = Object.keys(JSON.parse(defaultFile)[DEFAULT_LOCALE]); - - return gulp - .src([ - 'js/localization/messages/*.json', - '!js/localization/messages/en.json' - ]) - .pipe(through.obj(function(file, encoding, callback) { - const parsedFile = JSON.parse(file.contents.toString(encoding)); - - const [locale] = Object.keys(parsedFile); - const dictionary = parsedFile[locale]; - - let newFile = defaultFile.replace(`"${DEFAULT_LOCALE}"`, `"${locale}"`); - - defaultDictionaryKeys.forEach((key) => { - let replaceValue = null; - // eslint-disable-next-line no-prototype-builtins - if(dictionary.hasOwnProperty(key)) { - const val = dictionary[key]; - if(!val.includes('TODO')) { - replaceValue = val.replace(/"/g, '\\"'); - } - } - - if(replaceValue != null) { - newFile = newFile.replace(new RegExp(`"${key}":.*"(,)?`), `"${key}": "${replaceValue}"$1`); - } - }); - - file.contents = Buffer.from(newFile, encoding); - callback(null, file); - })) - .pipe(gulp.dest(DICTIONARY_SOURCE_FOLDER)); -}); +gulp.task('generate-community-locales', shell.task('pnpm nx build:community-localization devextreme')); diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 269b74713c38..ecce6699af39 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -190,7 +190,7 @@ "build-dist": "cross-env BUILD_ESM_PACKAGE=true gulp default --uglify", "build:modular": "cd ./playground/modular && webpack", "build:modular:watch": "cd ./playground/modular && webpack --watch", - "build:community-localization": "gulp generate-community-locales", + "build:community-localization": "pnpm nx build:community-localization devextreme", "build:systemjs": "gulp transpile-systemjs", "dev": "cross-env DEVEXTREME_TEST_CI=true gulp dev", "dev:watch": "cross-env DEVEXTREME_TEST_CI=true gulp dev-watch", diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 72ccb3f9481f..de3748167758 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -50,6 +50,14 @@ "{projectRoot}/js/__internal/core/localization/cldr-data" ] }, + "build:community-localization": { + "cache": false, + "executor": "devextreme-nx-infra-plugin:generate-community-locales", + "options": { + "messagesDir": "./js/localization/messages", + "defaultLocale": "en" + } + }, "build:localization": { "cache": true, "executor": "nx:run-commands", diff --git a/packages/nx-infra-plugin/AGENTS.md b/packages/nx-infra-plugin/AGENTS.md index 72be906c469c..99974510e5e4 100644 --- a/packages/nx-infra-plugin/AGENTS.md +++ b/packages/nx-infra-plugin/AGENTS.md @@ -73,6 +73,7 @@ Gulp tasks that have been migrated to Nx executor targets (the gulp task is now | ---------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clean` | `clean:artifacts` | Uses `devextreme-nx-infra-plugin:clean` with `excludePatterns` to preserve `artifacts/css`, `artifacts/npm/devextreme/package.json`, and `artifacts/npm/devextreme-dist`. The gulp task delegates via `shell.task('pnpm nx clean:artifacts devextreme')`. | | `bundler-config-watch` | `build:devextreme-bundler-config:watch` | Uses `devextreme-nx-infra-plugin:concatenate-files` in `watch` mode (chokidar over the `build/bundle-templates/modules/parts` sources) with `additionalPasses` so a change rebuilds both `dx.custom.js` and the derived `dx.custom.config.js`, matching the gulp `bundler-config` chain. `cache: false`. The non-watch gulp `bundler-config` now also delegates to the existing `build:devextreme-bundler-config` target (default + `-c prod`). File deletion is deferred until `dev-watch` is migrated. | +| `generate-community-locales` | `build:community-localization` | Uses `devextreme-nx-infra-plugin:generate-community-locales` to normalize `js/localization/messages/*.json` against `en.json` in place (fills translations, English fallback for missing/`TODO` values, escapes quotes, inherits en's key order/formatting). Target is `cache: false` with no `outputs` (input dir == output dir — a source-normalization task, not a cached build artifact). The gulp task delegates via `shell.task('pnpm nx build:community-localization devextreme')`. | When migrating additional gulp tasks, follow the same pattern: diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index 1be9f2301656..aa8b5c56283a 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -60,6 +60,11 @@ "schema": "./src/executors/localization/schema.json", "description": "Generate localization message files and TypeScript CLDR data modules" }, + "generate-community-locales": { + "implementation": "./src/executors/generate-community-locales/executor", + "schema": "./src/executors/generate-community-locales/schema.json", + "description": "Normalize community locale message files against the default locale dictionary" + }, "concatenate-files": { "implementation": "./src/executors/concatenate-files/executor", "schema": "./src/executors/concatenate-files/schema.json", diff --git a/packages/nx-infra-plugin/src/executors/generate-community-locales/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/generate-community-locales/executor.e2e.spec.ts new file mode 100644 index 000000000000..9f49654426a7 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/generate-community-locales/executor.e2e.spec.ts @@ -0,0 +1,111 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import executor from './executor'; +import { GenerateCommunityLocalesExecutorSchema } from './schema'; +import { + writeFileText, + readFileText, + cleanupTempDir, + createTempDir, + createMockContext, +} from '../../utils'; + +const PROJECT_SUBPATH = ['packages', 'test-lib'] as const; + +const EN_JSON = `{ + "en": { + "Yes": "Yes", + "No": "No", + "Cancel": "Cancel", + "Quote": "Say \\"hi\\"", + "Loading": "Loading..." + } +} +`; + +const FR_JSON_INPUT = `{ + "fr": { + "Yes": "Oui", + "No": "TODO: Non", + "Quote": "Dire \\"salut\\"", + "Loading": "Chargement...", + "Extra": "Extra" + } +} +`; + +// "No" keeps the English default (source value contains TODO), "Cancel" keeps the +// English default (missing in fr), "Extra" is dropped (absent from en), quotes are +// escaped, and en.json's key order/indentation/trailing newline are inherited verbatim. +const EXPECTED_FR = `{ + "fr": { + "Yes": "Oui", + "No": "No", + "Cancel": "Cancel", + "Quote": "Dire \\"salut\\"", + "Loading": "Chargement..." + } +} +`; + +interface Fixture { + projectDir: string; + messagesDir: string; +} + +async function createFixture(tempDir: string): Promise { + const projectDir = path.join(tempDir, ...PROJECT_SUBPATH); + const messagesDir = path.join(projectDir, 'js', 'localization', 'messages'); + + fs.mkdirSync(messagesDir, { recursive: true }); + + await writeFileText(path.join(messagesDir, 'en.json'), EN_JSON); + await writeFileText(path.join(messagesDir, 'fr.json'), FR_JSON_INPUT); + + return { projectDir, messagesDir }; +} + +describe('GenerateCommunityLocalesExecutor E2E', () => { + let tempDir: string; + let context = createMockContext(); + let fixture: Fixture; + + beforeEach(async () => { + tempDir = createTempDir('nx-community-locales-e2e-'); + context = createMockContext({ root: tempDir }); + fixture = await createFixture(tempDir); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('normalizes community locale files against the default locale', async () => { + const options: GenerateCommunityLocalesExecutorSchema = { + messagesDir: './js/localization/messages', + defaultLocale: 'en', + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const frContent = await readFileText(path.join(fixture.messagesDir, 'fr.json')); + expect(frContent).toBe(EXPECTED_FR); + }); + + it('leaves the default locale file untouched', async () => { + const result = await executor({}, context); + + expect(result.success).toBe(true); + + const enContent = await readFileText(path.join(fixture.messagesDir, 'en.json')); + expect(enContent).toBe(EN_JSON); + }); + + it('fails when the messages directory is missing', async () => { + const result = await executor({ messagesDir: './js/localization/does-not-exist' }, context); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/nx-infra-plugin/src/executors/generate-community-locales/executor.ts b/packages/nx-infra-plugin/src/executors/generate-community-locales/executor.ts new file mode 100644 index 000000000000..76ffddcce1f7 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/generate-community-locales/executor.ts @@ -0,0 +1 @@ +export { default } from './generate-community-locales.impl'; diff --git a/packages/nx-infra-plugin/src/executors/generate-community-locales/generate-community-locales.impl.ts b/packages/nx-infra-plugin/src/executors/generate-community-locales/generate-community-locales.impl.ts new file mode 100644 index 000000000000..4d3b269986b8 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/generate-community-locales/generate-community-locales.impl.ts @@ -0,0 +1,105 @@ +import { logger } from '@nx/devkit'; +import * as path from 'path'; +import * as fs from 'fs'; +import { createExecutor } from '../../utils/create-executor'; +import { readFileText, writeFileText } from '../../utils/file-operations'; +import { discoverFiles } from '../../utils/glob-discovery'; +import { GenerateCommunityLocalesExecutorSchema } from './schema'; + +const DEFAULT_MESSAGES_DIR = './js/localization/messages'; +const DEFAULT_LOCALE = 'en'; +const TODO_MARKER = 'TODO'; + +const ERROR_MESSAGES = { + MESSAGES_DIR_NOT_FOUND: (directory: string) => `Messages directory not found: ${directory}`, + DEFAULT_LOCALE_NOT_FOUND: (filePath: string) => `Default locale file not found: ${filePath}`, +} as const; + +interface LocaleDictionary { + [key: string]: string; +} + +function normalizeLocaleFile( + defaultFile: string, + defaultDictionaryKeys: string[], + defaultLocale: string, + fileContents: string, +): string { + const parsedFile = JSON.parse(fileContents) as Record; + + const [locale] = Object.keys(parsedFile); + const dictionary = parsedFile[locale]; + + let newFile = defaultFile.replace(`"${defaultLocale}"`, `"${locale}"`); + + defaultDictionaryKeys.forEach((key) => { + let replaceValue: string | null = null; + // eslint-disable-next-line no-prototype-builtins + if (dictionary.hasOwnProperty(key)) { + const val = dictionary[key]; + if (!val.includes(TODO_MARKER)) { + replaceValue = val.replace(/"/g, '\\"'); + } + } + + if (replaceValue != null) { + newFile = newFile.replace(new RegExp(`"${key}":.*"(,)?`), `"${key}": "${replaceValue}"$1`); + } + }); + + return newFile; +} + +interface ResolvedGenerateCommunityLocales { + messagesDir: string; + defaultLocale: string; +} + +export default createExecutor< + GenerateCommunityLocalesExecutorSchema, + ResolvedGenerateCommunityLocales +>({ + name: 'Generate Community Locales', + resolve: (options, { projectRoot }) => ({ + messagesDir: path.join(projectRoot, options.messagesDir || DEFAULT_MESSAGES_DIR), + defaultLocale: options.defaultLocale || DEFAULT_LOCALE, + }), + run: async ({ messagesDir, defaultLocale }) => { + if (!fs.existsSync(messagesDir)) { + throw new Error(ERROR_MESSAGES.MESSAGES_DIR_NOT_FOUND(messagesDir)); + } + + const defaultFilePath = path.join(messagesDir, `${defaultLocale}.json`); + if (!fs.existsSync(defaultFilePath)) { + throw new Error(ERROR_MESSAGES.DEFAULT_LOCALE_NOT_FOUND(defaultFilePath)); + } + + const defaultFile = await readFileText(defaultFilePath); + const defaultDictionaryKeys = Object.keys( + (JSON.parse(defaultFile) as Record)[defaultLocale], + ); + + const localeFiles = await discoverFiles({ + cwd: messagesDir, + includePatterns: ['*.json'], + excludePatterns: [`${defaultLocale}.json`], + }); + + logger.verbose(`Normalizing ${localeFiles.length} community locale files...`); + + await Promise.all( + localeFiles.map(async (filePath) => { + const fileContents = await readFileText(filePath); + const newFile = normalizeLocaleFile( + defaultFile, + defaultDictionaryKeys, + defaultLocale, + fileContents, + ); + await writeFileText(filePath, newFile); + }), + ); + + logger.verbose(`Community locale files normalized in ${messagesDir}`); + }, +}); diff --git a/packages/nx-infra-plugin/src/executors/generate-community-locales/schema.json b/packages/nx-infra-plugin/src/executors/generate-community-locales/schema.json new file mode 100644 index 000000000000..8fc0c2bee210 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/generate-community-locales/schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/schema", + "title": "Generate Community Locales Executor Schema", + "description": "Normalize community locale message files against the default locale dictionary", + "type": "object", + "properties": { + "messagesDir": { + "type": "string", + "description": "Directory containing locale message JSON files (e.g., en.json, de.json)", + "default": "./js/localization/messages" + }, + "defaultLocale": { + "type": "string", + "description": "Locale used as the canonical source of keys, ordering and formatting", + "default": "en" + } + } +} diff --git a/packages/nx-infra-plugin/src/executors/generate-community-locales/schema.ts b/packages/nx-infra-plugin/src/executors/generate-community-locales/schema.ts new file mode 100644 index 000000000000..5366cd0c8a84 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/generate-community-locales/schema.ts @@ -0,0 +1,4 @@ +export interface GenerateCommunityLocalesExecutorSchema { + messagesDir?: string; + defaultLocale?: string; +} From 711ceb230f9b5670535da416417d5264b0735883 Mon Sep 17 00:00:00 2001 From: Andrei Vorobev Date: Mon, 13 Jul 2026 18:00:50 +0300 Subject: [PATCH 2/4] chore: remove verbose comment --- .../executors/generate-community-locales/executor.e2e.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/generate-community-locales/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/generate-community-locales/executor.e2e.spec.ts index 9f49654426a7..57da42b1749f 100644 --- a/packages/nx-infra-plugin/src/executors/generate-community-locales/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/generate-community-locales/executor.e2e.spec.ts @@ -34,9 +34,6 @@ const FR_JSON_INPUT = `{ } `; -// "No" keeps the English default (source value contains TODO), "Cancel" keeps the -// English default (missing in fr), "Extra" is dropped (absent from en), quotes are -// escaped, and en.json's key order/indentation/trailing newline are inherited verbatim. const EXPECTED_FR = `{ "fr": { "Yes": "Oui", From 85d5b116644a987261ad486f7ce6ed176dc656ec Mon Sep 17 00:00:00 2001 From: Andrey Vorobev Date: Tue, 14 Jul 2026 09:33:30 +0300 Subject: [PATCH 3/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Andrey Vorobev --- .../generate-community-locales.impl.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/nx-infra-plugin/src/executors/generate-community-locales/generate-community-locales.impl.ts b/packages/nx-infra-plugin/src/executors/generate-community-locales/generate-community-locales.impl.ts index 4d3b269986b8..afb550e72f70 100644 --- a/packages/nx-infra-plugin/src/executors/generate-community-locales/generate-community-locales.impl.ts +++ b/packages/nx-infra-plugin/src/executors/generate-community-locales/generate-community-locales.impl.ts @@ -34,8 +34,7 @@ function normalizeLocaleFile( defaultDictionaryKeys.forEach((key) => { let replaceValue: string | null = null; - // eslint-disable-next-line no-prototype-builtins - if (dictionary.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(dictionary, key)) { const val = dictionary[key]; if (!val.includes(TODO_MARKER)) { replaceValue = val.replace(/"/g, '\\"'); From 785907fddc8733d2ae784371217108fa7d67cbce Mon Sep 17 00:00:00 2001 From: Andrei Vorobev <738482+vorobey@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:09:40 +0300 Subject: [PATCH 4/4] chore: fix linting --- packages/nx-infra-plugin/AGENTS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/nx-infra-plugin/AGENTS.md b/packages/nx-infra-plugin/AGENTS.md index 99974510e5e4..d35bd3891c9e 100644 --- a/packages/nx-infra-plugin/AGENTS.md +++ b/packages/nx-infra-plugin/AGENTS.md @@ -69,11 +69,11 @@ Each behavior is owned by exactly ONE executor's canonical tests; consumers must Gulp tasks that have been migrated to Nx executor targets (the gulp task is now a thin `shell.task` delegate): -| Gulp task | Nx target | Notes | -| ---------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `clean` | `clean:artifacts` | Uses `devextreme-nx-infra-plugin:clean` with `excludePatterns` to preserve `artifacts/css`, `artifacts/npm/devextreme/package.json`, and `artifacts/npm/devextreme-dist`. The gulp task delegates via `shell.task('pnpm nx clean:artifacts devextreme')`. | -| `bundler-config-watch` | `build:devextreme-bundler-config:watch` | Uses `devextreme-nx-infra-plugin:concatenate-files` in `watch` mode (chokidar over the `build/bundle-templates/modules/parts` sources) with `additionalPasses` so a change rebuilds both `dx.custom.js` and the derived `dx.custom.config.js`, matching the gulp `bundler-config` chain. `cache: false`. The non-watch gulp `bundler-config` now also delegates to the existing `build:devextreme-bundler-config` target (default + `-c prod`). File deletion is deferred until `dev-watch` is migrated. | -| `generate-community-locales` | `build:community-localization` | Uses `devextreme-nx-infra-plugin:generate-community-locales` to normalize `js/localization/messages/*.json` against `en.json` in place (fills translations, English fallback for missing/`TODO` values, escapes quotes, inherits en's key order/formatting). Target is `cache: false` with no `outputs` (input dir == output dir — a source-normalization task, not a cached build artifact). The gulp task delegates via `shell.task('pnpm nx build:community-localization devextreme')`. | +| Gulp task | Nx target | Notes | +| ---------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `clean` | `clean:artifacts` | Uses `devextreme-nx-infra-plugin:clean` with `excludePatterns` to preserve `artifacts/css`, `artifacts/npm/devextreme/package.json`, and `artifacts/npm/devextreme-dist`. The gulp task delegates via `shell.task('pnpm nx clean:artifacts devextreme')`. | +| `bundler-config-watch` | `build:devextreme-bundler-config:watch` | Uses `devextreme-nx-infra-plugin:concatenate-files` in `watch` mode (chokidar over the `build/bundle-templates/modules/parts` sources) with `additionalPasses` so a change rebuilds both `dx.custom.js` and the derived `dx.custom.config.js`, matching the gulp `bundler-config` chain. `cache: false`. The non-watch gulp `bundler-config` now also delegates to the existing `build:devextreme-bundler-config` target (default + `-c prod`). File deletion is deferred until `dev-watch` is migrated. | +| `generate-community-locales` | `build:community-localization` | Uses `devextreme-nx-infra-plugin:generate-community-locales` to normalize `js/localization/messages/*.json` against `en.json` in place (fills translations, English fallback for missing/`TODO` values, escapes quotes, inherits en's key order/formatting). Target is `cache: false` with no `outputs` (input dir == output dir — a source-normalization task, not a cached build artifact). The gulp task delegates via `shell.task('pnpm nx build:community-localization devextreme')`. | When migrating additional gulp tasks, follow the same pattern: