Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 1 addition & 43 deletions packages/devextreme/build/gulp/localization.js
Original file line number Diff line number Diff line change
@@ -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'));
2 changes: 1 addition & 1 deletion packages/devextreme/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions packages/devextreme/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions packages/nx-infra-plugin/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +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. |
| 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:

Expand Down
5 changes: 5 additions & 0 deletions packages/nx-infra-plugin/executors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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"
}
}
`;

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<Fixture> {
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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './generate-community-locales.impl';
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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<string, LocaleDictionary>;

const [locale] = Object.keys(parsedFile);
const dictionary = parsedFile[locale];

let newFile = defaultFile.replace(`"${defaultLocale}"`, `"${locale}"`);

defaultDictionaryKeys.forEach((key) => {
let replaceValue: string | null = null;
if (Object.prototype.hasOwnProperty.call(dictionary, 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<string, LocaleDictionary>)[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}`);
},
});
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface GenerateCommunityLocalesExecutorSchema {
messagesDir?: string;
defaultLocale?: string;
}
Loading