Skip to content

Feat: new command "custom-label-translations" #1138

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
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
153 changes: 153 additions & 0 deletions docs/hardis/misc/custom-label-translations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# hardis:misc:custom-label-translations

## Description

Extract selected custom labels, or of a given Lightning Web Component (LWC), from all language translation files. This command generates translation files (`*.translation-meta.xml`) for each language already retrieved in the current project, containing only the specified custom labels.

This makes it easier to:
- Translate specific custom labels
- Deploy specific custom label translations to another org
- Manage translations for LWC components

## Parameters

| Name | Type | Description | Default | Required | Options |
|:------------------|:-------:|:--------------------------------------------------------------|:-------:|:--------:|:-------:|
| label<br/>-l | string | Developer name(s) of the custom label(s), comma-separated | | * | |
| lwc<br/>-c | string | Developer name of the Lightning Web Component | | * | |
| debug<br/>-d | boolean | Activate debug mode (more logs) | false | | |
| websocket | string | Websocket host:port for VsCode SFDX Hardis UI integration | | | |
| skipauth | boolean | Skip authentication check when a default username is required | | | |

\* Either `label` or `lwc` must be provided, not both

## Examples

```shell
# Extract specific custom labels
sf hardis:misc:custom-label-translations --label CustomLabelName
sf hardis:misc:custom-label-translations --label Label1,Label2

# Extract custom labels used in a Lightning Web Component
sf hardis:misc:custom-label-translations --lwc MyComponent
```

## How It Works

### Example 1: Extract specific Custom Labels

If you have the following translation files in your project:

**pt_BR.translation-meta.xml** 🇧🇷
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Translations xmlns="http://soap.sforce.com/2006/04/metadata">
<customLabels>
<label>Teste</label>
<name>Test</name>
</customLabels>
<customLabels>
<label>Olá</label>
<name>Hello</name>
</customLabels>
</Translations>
```

**es.translation-meta.xml** 🇪🇸
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Translations xmlns="http://soap.sforce.com/2006/04/metadata">
<customLabels>
<label>Teste</label>
<name>Test</name>
</customLabels>
<customLabels>
<label>Hola</label>
<name>Hello</name>
</customLabels>
</Translations>
```

Running the command:
```shell
sf hardis:misc:custom-label-translations --label Hello
```

Will generate the following files in `extracted-translations/extract-{timestamp}/`:

**pt_BR.translation-meta.xml** 🇧🇷
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Translations xmlns="http://soap.sforce.com/2006/04/metadata">
<customLabels>
<label>Olá</label>
<name>Hello</name>
</customLabels>
</Translations>
```

**es.translation-meta.xml** 🇪🇸
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Translations xmlns="http://soap.sforce.com/2006/04/metadata">
<customLabels>
<label>Hola</label>
<name>Hello</name>
</customLabels>
</Translations>
```

### Example 2: Extract from LWC

For a Lightning Web Component that imports custom labels:

```js
import error from '@salesforce/label/c.error';
import success from '@salesforce/label/c.success';
export default class MyComponent extends LightningElement {
// Component code
}
```

Running the command:
```shell
sf hardis:misc:custom-label-translations --lwc MyComponent
```

Will generate the following files in `extracted-translations/MyComponent-{timestamp}/`:

**pt_BR.translation-meta.xml** 🇧🇷
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Translations xmlns="http://soap.sforce.com/2006/04/metadata">
<customLabels>
<label>Erro</label>
<name>error</name>
</customLabels>
<customLabels>
<label>Sucesso</label>
<name>success</name>
</customLabels>
</Translations>
```

**es.translation-meta.xml** 🇪🇸
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Translations xmlns="http://soap.sforce.com/2006/04/metadata">
<customLabels>
<label>Error</label>
<name>error</name>
</customLabels>
<customLabels>
<label>Éxito</label>
<name>success</name>
</customLabels>
</Translations>
```

## Notes

- The command searches for translation files in the `**/translations/` directory
- Output files are created in the `extracted-translations` directory with a timestamp
- When extracting labels from an LWC, the output directory name includes the LWC name
227 changes: 227 additions & 0 deletions src/commands/hardis/misc/custom-label-translations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import c from "chalk";
import * as path from "path";
import fs from "fs-extra";
import { Messages } from '@salesforce/core';
import { AnyJson } from '@salesforce/ts-types';
import { isCI, uxLog } from '../../../common/utils/index.js';
import { MetadataUtils } from '../../../common/metadata-utils/index.js';
import { WebSocketClient } from '../../../common/websocketClient.js';
import { parseStringPromise, Builder } from 'xml2js';
import { glob } from 'glob';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');

export default class CustomLabelTranslations extends SfCommand<any> {
public static title = 'Custom Label Translations';

public static description = `Extract selected custom labels, or of a given LWC, from all language translation files`;

public static examples = [
'$ sf hardis:misc:custom-label-translations --label CustomLabelName',
'$ sf hardis:misc:custom-label-translations --label Label1,Label2',
'$ sf hardis:misc:custom-label-translations --lwc MyComponent'
];

private outputDirPrefix = 'extract-';

public static flags: any = {
label: Flags.string({
char: 'l',
description: 'Developer name(s) of the custom label(s), comma-separated',
}),
lwc: Flags.string({
char: 'c',
description: 'Developer name of the Lightning Web Component',
}),
debug: Flags.boolean({
char: 'd',
default: false,
description: messages.getMessage('debugMode'),
}),
websocket: Flags.string({
description: messages.getMessage('websocket'),
}),
skipauth: Flags.boolean({
description: 'Skip authentication check when a default username is required',
}),
};

// Set this to true if your command requires a project workspace; 'requiresProject' is false by default
public static requiresProject = true;

/**
* Extract custom label names from LWC JS files
*/
private async extractLabelsFromLwc(lwcName: string, debugMode: boolean): Promise<string[]> {
uxLog(this, c.grey(`Looking for LWC '${lwcName}' JS files...`));

const lwcFiles = await glob(`**/lwc/${lwcName}/**/*.js`);

if (lwcFiles.length === 0) {
throw new Error(`No JS files found for LWC '${lwcName}'`);
}

uxLog(this, c.grey(`Found ${lwcFiles.length} JS files for component '${lwcName}'`));

const labelNames = new Set<string>();
const labelImportRegex = /@salesforce\/label\/c\.([a-zA-Z0-9_]+)/g;

for (const jsFile of lwcFiles) {
const content = await fs.readFile(jsFile, 'utf8');

let match;
while ((match = labelImportRegex.exec(content)) !== null) {
labelNames.add(match[1]);
}

if (debugMode) {
uxLog(this, c.grey(`Processed file: ${jsFile}`));
}
}

const extractedLabels = Array.from(labelNames);

if (extractedLabels.length === 0) {
throw new Error(`No custom labels found in LWC '${lwcName}'`);
}

uxLog(this, c.grey(`Found ${extractedLabels.length} custom labels in LWC '${lwcName}': ${extractedLabels.join(', ')}`));
this.outputDirPrefix = lwcName;

return extractedLabels;
}

public async run(): Promise<AnyJson> {
const { flags } = await this.parse(CustomLabelTranslations);
const debugMode = flags.debug || false;

let labelNames: string[] = [];

if (flags.lwc) {
try {
labelNames = await this.extractLabelsFromLwc(flags.lwc, debugMode);
} catch (error: any) {
uxLog(this, c.red(error.message));
return { success: false, message: error.message };
}
} else if (flags.label) {
labelNames = flags.label.split(',').map(label => label.trim());
} else if (!isCI) {
const selection = await MetadataUtils.promptExtractionMethod();
if (selection.type == 'labels') {
labelNames = selection.values;
} else if (selection.type == 'lwc') {
labelNames = await this.extractLabelsFromLwc(selection.values, debugMode);
}
}

if (!labelNames || labelNames.length === 0) {
const errorMsg = 'No custom labels specified. Use --label or --lwc flag.';
uxLog(this, c.red(errorMsg));
return { success: false, message: errorMsg };
}

uxLog(this, c.grey(`Processing custom labels: ${labelNames.join(', ')}`));

try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const outputDir = path.join('extracted-translations', `${this.outputDirPrefix}-${timestamp}`);
await fs.ensureDir(outputDir);

const translationFiles = await glob('**/translations/*.translation-meta.xml');

if (translationFiles.length === 0) {
uxLog(this, c.yellow(`No translation files found in **/translations/`));
return { success: false, message: 'No translation files found' };
}

const results = {};

for (const translationFile of translationFiles) {
const languageCode = path.basename(translationFile).replace('.translation-meta.xml', '');
uxLog(this, c.grey(`Processing translation file for ${languageCode}...`));

const xmlContent = await fs.readFile(translationFile, 'utf8');

const parsedXml = await parseStringPromise(xmlContent, { explicitArray: false });

if (!parsedXml.Translations) {
uxLog(this, c.yellow(`Invalid translation file format: ${translationFile}`));
continue;
}

if (!parsedXml.Translations.customLabels) {
uxLog(this, c.yellow(`No custom labels found in ${translationFile}`));
continue;
}

const customLabels = Array.isArray(parsedXml.Translations.customLabels)
? parsedXml.Translations.customLabels
: [parsedXml.Translations.customLabels];

const matchedLabels = customLabels.filter(label =>
labelNames.includes(label.name)
);

if (matchedLabels.length === 0) {
uxLog(this, c.yellow(`No matching custom labels found in ${languageCode}`));
continue;
}

const newXml = {
Translations: {
$: { xmlns: "http://soap.sforce.com/2006/04/metadata" },
customLabels: matchedLabels
}
};

const builder = new Builder({
xmldec: { version: '1.0', encoding: 'UTF-8' },
renderOpts: { pretty: true, indent: ' ', newline: '\n' }
});
const outputXml = builder.buildObject(newXml);

const outputFile = path.join(outputDir, `${languageCode}.translation-meta.xml`);

await fs.writeFile(outputFile, outputXml);

results[languageCode] = {
file: outputFile,
matchedLabels: matchedLabels.length
};

if (debugMode) {
uxLog(this, c.grey(`Found ${matchedLabels.length} labels in ${languageCode}:`));
matchedLabels.forEach(label => {
uxLog(this, c.grey(` ${label.name} = "${label.label}"`));
});
}
}

const totalFiles = Object.keys(results).length;

if (totalFiles === 0) {
uxLog(this, c.yellow('No matching labels found in any translation file.'));
return { success: false, message: 'No matching labels found' };
}

uxLog(this, c.green(`Successfully extracted custom labels to ${outputDir}`));
uxLog(this, c.grey(`Processed ${totalFiles} translation files`));

WebSocketClient.requestOpenFile(outputDir);

// Return an object to be displayed with --json
return {
success: true,
outputDirectory: outputDir,
results: results
};

} catch (err: any) {
uxLog(this, c.red(`Error processing custom labels: ${err.message}`));
throw err;
}
}
}
Loading