Skip to content
Open
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
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,17 @@ install-state.gz
!.yarn/releases
!.yarn/sdks
!.yarn/versions

!/vendor/**
!.vscode/settings.json
.DS_Store
cypress-a11y-report.json
/bin
/gopath
/Godeps/_workspace/src/github.com/openshift/console
/frontend/.cache-loader
/frontend/.puppeteer
/frontend/.webpack-cycles
/frontend/__coverage__
/frontend/__chrome_browser__
/frontend/**/node_modules
/frontend/**/npm-debug.log
/frontend/**/yarn-error.log
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.plugins.demo
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ RUN mkdir -p /src/console
COPY . /src/console

WORKDIR /src/console/frontend
RUN yarn install && yarn build-plugin-sdk
RUN yarn install && yarn generate

WORKDIR /src/console/dynamic-demo-plugin
RUN yarn install && yarn build
Expand Down
4 changes: 2 additions & 2 deletions INTERNATIONALIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ cd frontend
yarn i18n
```

This command launches the [code parser](https://github.com/i18next/i18next-parser), generates JSON files containing English key:value pairs for all internationalized strings, and consolidates any English JSON files with identical names to avoid namespace conflicts in i18next.
This command launches the [code parser](https://github.com/i18next/i18next-cli), generates JSON files containing English key:value pairs for all internationalized strings, and consolidates any English JSON files with identical names to avoid namespace conflicts in i18next.

#### Scope
We are not able to translate all text in the application. Text located in backend code or non-Red-Hat-controlled development environments may not be accessible for translation.
Expand Down Expand Up @@ -55,7 +55,7 @@ Good: t('public~Hello, it is now {{date}}', { date: new Date() })
```
model.labelPluralKey ? t(model.labelPluralKey) : model.labelPlural
```
* While i18next extracts translation keys in runtime, i18next-parser (the tool we use to generate JSON files) doesn't run the code, so it can't interpolate values in these expressions:
* At runtime, i18next resolves translation keys to translated text. At build time, i18next-cli (the tool we use to generate JSON files) extracts keys from source code via static analysis without executing it, so it cannot resolve dynamic expressions like:

```
t(key)
Expand Down
1 change: 0 additions & 1 deletion clean-frontend.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#!/usr/bin/env bash

find frontend -type d -name 'node_modules' -prune -exec rm -rf {} \;
rm -rf frontend/.cache-loader
rm -rf frontend/public/dist
48 changes: 3 additions & 45 deletions dynamic-demo-plugin/i18next.config.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,5 @@
import { readFile } from 'node:fs/promises';
import jsonc from 'comment-json';
import { defineConfig, Plugin } from 'i18next-cli';

/**
* Custom JSON parser for localizing keys matching format: /%.+%/
*/
const consoleExtensionsPlugin = (): Plugin => ({
name: 'console-extensions',

async onEnd(keys) {
const content = await readFile('console-extensions.json', 'utf-8');
const extracted: { key: string }[] = [];

try {
jsonc.parse(
content,
(_key, value) => {
if (typeof value === 'string') {
const match = value.match(/^%(.+)%$/);
if (match && match[1]) {
extracted.push({ key: match[1] });
}
}
return value;
},
true,
);
} catch (e) {
console.error('Failed to parse as JSON.', e);
extracted.length = 0;
}

for (const { key: fullKey } of extracted) {
const [ns, key] = fullKey.split('~', 2);

if (ns && key) {
keys.set(`${ns}:${key}`, { key, defaultValue: key, ns });
} else {
console.warn(`Invalid key format: ${fullKey}`);
}
}
},
});
import { ConsoleExtensionsI18nextCliPlugin } from '../frontend/i18n-scripts/ConsoleExtensionsI18nextCliPlugin';
import { defineConfig } from 'i18next-cli';

export default defineConfig({
locales: ['en'],
Expand All @@ -54,5 +12,5 @@ export default defineConfig({
nsSeparator: '~',
defaultNS: 'plugin__console-demo-plugin',
},
plugins: [consoleExtensionsPlugin()],
plugins: [ConsoleExtensionsI18nextCliPlugin()],
});
1 change: 0 additions & 1 deletion frontend/.prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
.cache-loader
.yarn
node_modules
public/dist
36 changes: 36 additions & 0 deletions frontend/__tests__/i18n-structure.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,42 @@ describe('i18n structure', () => {
expect(mismatches).toEqual([]);
});

it('must have different values for _one and _other plural keys in EN', () => {
const enLocaleFiles = LOCALE_FILES.filter((file) => file.includes('/en/'));
const identical: string[] = [];

for (const file of enLocaleFiles) {
const raw = readFileSync(resolve(FRONTEND_DIR, file), 'utf-8');
const data = JSON.parse(raw);
const lines = raw.split('\n');

for (const [key, value] of Object.entries(data)) {
if (key.endsWith('_one')) {
const base = key.slice(0, -4);
const otherValue = data[`${base}_other`];
if (otherValue !== undefined && value === otherValue) {
const line = lines.findIndex((l) => l.includes(`"${key}"`)) + 1;
identical.push(`${file}:${line}: "${base}"`);
}
}
}
}

if (identical.length > 0) {
throw new Error(
[
'Found _one/_other pairs with identical values:',
'',
...identical,
'',
'Hint: If the singular and plural forms are genuinely identical, use a',
'variable name other than "count" (e.g. "numberOf") to avoid i18next',
'plural resolution and use a single key instead of _one/_other pairs.',
].join('\n'),
);
}
});

it('must contain only one file per locale directory', () => {
const dirCounts: Record<string, string[]> = {};
for (const file of LOCALE_FILES) {
Expand Down
77 changes: 77 additions & 0 deletions frontend/i18n-scripts/ConsoleExtensionsI18nextCliPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/* eslint-disable no-console */
import { readFile } from 'node:fs/promises';
import chalk from 'chalk';
import { parse } from 'comment-json';
import type { Plugin } from 'i18next-cli';

export interface ConsoleExtensionsI18nextCliPluginOptions {
/**
* Paths to the console-extensions.json file, assumed to be encoded as UTF-8.
*
* Defaults to `console-extensions.json` in the current working directory.
*/
paths?: string[];
}

/**
* A [i18next-cli] plugin to extract translation keys from `console-extensions.json` files.
*
* Keys matching the format `%namespace~key%` are extracted.
*
* NOTE: `i18next-cli` does not appear to fully respect semver. Compatibility is only
* known if you are using the exact same version of `i18next-cli` that Console is using.
*
* @returns a [i18next-cli] plugin instance
*
* [i18next-cli]: https://github.com/i18next/i18next-cli
*/
export const ConsoleExtensionsI18nextCliPlugin = ({
paths = ['console-extensions.json'],
}: ConsoleExtensionsI18nextCliPluginOptions = {}): Plugin => ({
name: 'console-extensions',

async onEnd(keys) {
const files = await Promise.all(
paths.map((path) =>
readFile(path, 'utf-8').catch(() => {
console.warn(chalk.yellowBright(`Warning: Could not read file at ${path}. Skipping.`));
return '{}';
}),
),
);

for (const [idx, content] of files.entries()) {
const extracted: { key: string }[] = [];

try {
parse(
content,
(_key, value) => {
if (typeof value === 'string') {
const match = value.match(/^%(.+)%$/);
if (match && match[1]) {
extracted.push({ key: match[1] });
}
}
return value;
},
true,
);
} catch (e) {
console.error(`Failed to parse ${paths[idx]}:`, e);
throw e;
}

for (const { key: fullKey } of extracted) {
const sep = fullKey.indexOf('~');
if (sep > 0 && sep < fullKey.length - 1) {
const ns = fullKey.slice(0, sep);
const key = fullKey.slice(sep + 1);
keys.set(`${ns}:${key}`, { key, defaultValue: key, ns });
} else {
console.warn(chalk.yellowBright(`Invalid key format in ${paths[idx]}: ${fullKey}`));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { readFile } from 'node:fs/promises';
import type { ExtractedKey } from 'i18next-cli';
import {
ConsoleExtensionsI18nextCliPlugin,
type ConsoleExtensionsI18nextCliPluginOptions,
} from '../ConsoleExtensionsI18nextCliPlugin';

jest.mock('node:fs/promises', () => ({
readFile: jest.fn(),
}));

const mockedReadFile = readFile as unknown as jest.Mock;

function getOnEnd(options?: ConsoleExtensionsI18nextCliPluginOptions) {
const { onEnd } = ConsoleExtensionsI18nextCliPlugin(options);
if (!onEnd) {
throw new Error('onEnd is not defined');
}
return onEnd;
}

describe('ConsoleExtensionsI18nextCliPlugin', () => {
let keys: Map<string, ExtractedKey>;

beforeEach(() => {
keys = new Map();
mockedReadFile.mockReset();
jest.spyOn(console, 'warn').mockImplementation();
jest.spyOn(console, 'error').mockImplementation();
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should fail gracefully with invalid JSON', async () => {
mockedReadFile.mockResolvedValue('{"key": "%test~value%", invalid}');
const onEnd = getOnEnd({ paths: ['test.json'] });
await expect(onEnd(keys)).rejects.toThrow();
});

it('should parse strings matching pattern `^%.+%$`', async () => {
mockedReadFile.mockResolvedValue(
'{"nope": false, "foo": "%ns~bar%", "test": ["%ns~arr1%", "%ns~arr2%", "arr3"]}',
);
const onEnd = getOnEnd({ paths: ['test.json'] });
await onEnd(keys);
expect([...keys.values()]).toEqual([
{ key: 'bar', defaultValue: 'bar', ns: 'ns' },
{ key: 'arr1', defaultValue: 'arr1', ns: 'ns' },
{ key: 'arr2', defaultValue: 'arr2', ns: 'ns' },
]);
});

it('should parse json with comments', async () => {
mockedReadFile.mockResolvedValue(
`{"nope": false,
// comment
"foo": "%ns~bar%", "test": ["%ns~arr1%",
// comment
"%ns~arr2%", "arr3"]}`,
);
const onEnd = getOnEnd({ paths: ['test.json'] });
await onEnd(keys);
expect([...keys.values()]).toEqual([
{ key: 'bar', defaultValue: 'bar', ns: 'ns' },
{ key: 'arr1', defaultValue: 'arr1', ns: 'ns' },
{ key: 'arr2', defaultValue: 'arr2', ns: 'ns' },
]);
});

it('should warn on keys without namespace separator', async () => {
mockedReadFile.mockResolvedValue('{"key": "%nonamespace%"}');
const onEnd = getOnEnd({ paths: ['test.json'] });
await onEnd(keys);
expect(keys.size).toBe(0);
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Invalid key format'));
});

it('should treat everything after the first ~ as the key', async () => {
mockedReadFile.mockResolvedValue('{"key": "%ns~key~suffix%"}');
const onEnd = getOnEnd({ paths: ['test.json'] });
await onEnd(keys);
expect([...keys.values()]).toEqual([
{ key: 'key~suffix', defaultValue: 'key~suffix', ns: 'ns' },
]);
});

it('should warn and skip when file cannot be read', async () => {
mockedReadFile.mockRejectedValue(new Error('ENOENT'));
const onEnd = getOnEnd({ paths: ['missing.json'] });
await onEnd(keys);
expect(keys.size).toBe(0);
expect(console.warn).toHaveBeenCalled();
});
});
Loading