sfdx-hardis
Version:
Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards
416 lines (408 loc) • 21.2 kB
JavaScript
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 { isCI, uxLog } from '../../../common/utils/index.js';
import { prompts } from '../../../common/utils/prompts.js';
import { WebSocketClient } from '../../../common/websocketClient.js';
import { parseStringPromise, Builder } from 'xml2js';
import { GLOB_IGNORE_PATTERNS } from '../../../common/utils/projectUtils.js';
import { glob } from 'glob';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');
export default class CustomLabelTranslations extends SfCommand {
static title = 'Custom Label Translations';
static description = `
## Command Behavior
**Extracts selected custom labels, or all custom labels used within a given Lightning Web Component (LWC), from all available language translation files in the project.**
This command streamlines the process of managing and isolating specific custom label translations. It's particularly useful for:
- **Localization Management:** Focusing on translations for a subset of labels or for labels relevant to a specific UI component.
- **Collaboration:** Sharing only the necessary translation files with translators, reducing complexity.
- **Debugging:** Isolating translation issues for specific labels or components.
Key functionalities:
- **Label Selection:** You can specify custom label names directly using the \`--label\` flag (comma-separated).
- **LWC-based Extraction:** Alternatively, you can provide an LWC developer name using the \`--lwc\` flag, and the command will automatically identify and extract all custom labels referenced within that LWC's JavaScript files.
- **Interactive Prompts:** If neither \`--label\` nor \`--lwc\` is provided, the command will interactively prompt you to choose between selecting specific labels or extracting from an LWC.
- **Output Generation:** For each language found in your project's \`translations\` folder, it generates a new \`.translation-meta.xml\` file containing only the extracted custom labels and their translations. These files are placed in a timestamped output directory.
<details markdown="1">
<summary>Technical explanations</summary>
The command's technical implementation involves:
- **File Discovery:** It uses \`glob\` to find all \`*.translation-meta.xml\` files in the \`**/translations/\` directory and, if an LWC is specified, it searches for the LWC's JavaScript files (\`**/lwc/**/*.js\`).
- **LWC Label Extraction:** The \`extractLabelsFromLwc\` function uses regular expressions (\`@salesforce/label/c.([a-zA-Z0-9_]+)\`) to parse LWC JavaScript files and identify referenced custom labels.
- **XML Parsing and Building:** It uses \`xml2js\` (\`parseStringPromise\` and \`Builder\`) to:
- Read and parse existing \`.translation-meta.xml\` files.
- Filter the \`customLabels\` array to include only the requested labels.
- Construct a new XML structure containing only the filtered labels.
- Build a new XML string with proper formatting and write it to a new file.
- **Interactive Prompts:** The \`prompts\` library is used extensively to guide the user through the selection of extraction methods (labels or LWC) and specific labels/components.
- **File System Operations:** It uses \`fs-extra\` for creating output directories (\`extracted-translations/\`) and writing the generated translation files.
- **WebSocket Communication:** It uses \`WebSocketClient.requestOpenFile\` to open the output directory in VS Code for easy access to the generated files.
</details>
`;
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'
];
outputDirPrefix = 'extract-';
static flags = {
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
static requiresProject = true;
/**
* Extract custom label names from LWC JS files
*/
async extractLabelsFromLwc(lwcName, debugMode) {
uxLog("log", 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("log", this, c.grey(`Found ${lwcFiles.length} JS files for component '${lwcName}'.`));
const labelNames = new Set();
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("log", 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("log", this, c.grey(`Found ${extractedLabels.length} custom labels in LWC '${lwcName}': ${extractedLabels.join(', ')}`));
this.outputDirPrefix = lwcName;
return extractedLabels;
}
/**
* Extract original custom label values from CustomLabels.labels-meta.xml
*/
async extractOriginalLabels(labelNames, debugMode) {
uxLog("log", this, c.grey(`Looking for original custom label definitions...`));
const originalLabels = new Map();
const customLabelsFiles = await glob('**/labels/CustomLabels.labels-meta.xml', { ignore: GLOB_IGNORE_PATTERNS });
if (customLabelsFiles.length === 0) {
uxLog("warning", this, c.yellow(`No CustomLabels.labels-meta.xml found.`));
return originalLabels;
}
for (const customLabelsFile of customLabelsFiles) {
const xmlContent = await fs.readFile(customLabelsFile, 'utf8');
const parsedXml = await parseStringPromise(xmlContent, { explicitArray: false });
if (!parsedXml.CustomLabels || !parsedXml.CustomLabels.labels) {
continue;
}
const labels = Array.isArray(parsedXml.CustomLabels.labels)
? parsedXml.CustomLabels.labels
: [parsedXml.CustomLabels.labels];
for (const label of labels) {
const labelName = label.fullName || label.name;
if (labelNames.includes(labelName)) {
originalLabels.set(labelName, {
name: labelName,
label: label.value || '',
shortDescription: label.shortDescription || '',
});
if (debugMode) {
uxLog("log", this, c.grey(`Found original: ${labelName} = "${label.value}"`));
}
}
}
}
uxLog("log", this, c.grey(`Found ${originalLabels.size} original label definitions.`));
return originalLabels;
}
async run() {
const { flags } = await this.parse(CustomLabelTranslations);
const debugMode = flags.debug || false;
let labelNames = [];
if (flags.lwc) {
try {
labelNames = await this.extractLabelsFromLwc(flags.lwc, debugMode);
}
catch (error) {
uxLog("error", 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 CustomLabelTranslations.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("error", this, c.red(errorMsg));
return { success: false, message: errorMsg };
}
uxLog("log", this, c.grey(`Processing custom labels: ${labelNames.join(', ')}`));
try {
const originalLabels = await this.extractOriginalLabels(labelNames, debugMode);
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("warning", 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("log", 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("warning", this, c.yellow(`Invalid translation file format: ${translationFile}.`));
continue;
}
if (!parsedXml.Translations.customLabels) {
uxLog("warning", 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("warning", 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("log", this, c.grey(`Found ${matchedLabels.length} labels in ${languageCode}:`));
matchedLabels.forEach(label => {
uxLog("log", this, c.grey(` ${label.name} = "${label.label}"`));
});
}
}
const totalFiles = Object.keys(results).length;
if (totalFiles === 0) {
uxLog("warning", this, c.yellow('No matching labels found in any translation file.'));
return { success: false, message: 'No matching labels found' };
}
uxLog("success", this, c.green(`Successfully extracted custom labels to ${outputDir}.`));
uxLog("log", this, c.grey(`Processed ${totalFiles} translation files.`));
if (originalLabels.size > 0) {
const originalXmlLabels = [];
for (const labelName of labelNames) {
const original = originalLabels.get(labelName);
if (original) {
originalXmlLabels.push({
name: original.name,
label: original.label,
shortDescription: original.shortDescription,
});
}
}
if (originalXmlLabels.length > 0) {
const originalXml = {
Translations: {
$: { xmlns: "http://soap.sforce.com/2006/04/metadata" },
customLabels: originalXmlLabels
}
};
const builder = new Builder({
xmldec: { version: '1.0', encoding: 'UTF-8' },
renderOpts: { pretty: true, indent: ' ', newline: '\n' }
});
const outputXml = builder.buildObject(originalXml);
const originalFile = path.join(outputDir, 'original.translation-meta.xml');
await fs.writeFile(originalFile, outputXml);
uxLog("log", this, c.grey(`Generated original labels file: original.translation-meta.xml`));
}
}
WebSocketClient.requestOpenFile(outputDir);
return {
success: true,
outputDirectory: outputDir,
results: results
};
}
catch (err) {
uxLog("error", this, c.red(`Error processing custom labels: ${err.message}`));
throw err;
}
}
static async promptCustomLabels() {
try {
const customLabelsFiles = await glob('**/labels/CustomLabels.labels-meta.xml', { ignore: GLOB_IGNORE_PATTERNS });
if (customLabelsFiles.length == 0) {
throw new Error('No CustomLabels.labels-meta.xml was found');
}
const choices = [];
for (const customLabelsFile of customLabelsFiles) {
const xmlContent = await fs.readFile(customLabelsFile, 'utf8');
const parsedXml = await parseStringPromise(xmlContent);
if (!parsedXml.CustomLabels || !parsedXml.CustomLabels.labels) {
throw new Error('No custom labels found in the file');
}
const labels = Array.isArray(parsedXml.CustomLabels.labels)
? parsedXml.CustomLabels.labels
: [parsedXml.CustomLabels.labels];
labels.sort((a, b) => {
const nameA = a.fullName ? a.fullName[0] : a.name ? a.name[0] : '';
const nameB = b.fullName ? b.fullName[0] : b.name ? b.name[0] : '';
return nameA.localeCompare(nameB, 'en', { sensitivity: 'base' });
});
labels.map(label => {
const name = label.fullName ? label.fullName[0] : label.name ? label.name[0] : '';
const value = label.value ? label.value[0] : '';
const shortDesc = value.length > 40 ? value.substring(0, 40) + '...' : value;
choices.push({
value: name,
title: name,
description: shortDesc
});
});
}
const labelSelectRes = await prompts({
type: 'multiselect',
message: 'Please select the Custom Labels you want to extract from translations',
description: 'Choose which custom labels to include in the translation extraction',
choices: choices
});
return labelSelectRes.value;
}
catch (err) {
console.error('Error while processing custom labels:', err.message);
throw err;
}
}
static async promptLwcComponent() {
try {
const lwcMetaFiles = await glob('**/lwc/*/*.js-meta.xml');
if (lwcMetaFiles.length === 0) {
throw new Error('No Lightning Web Components found in the project');
}
const componentsInfo = [];
for (const metaFile of lwcMetaFiles) {
try {
const xmlContent = await fs.readFile(metaFile, 'utf8');
const parsedXml = await parseStringPromise(xmlContent);
const pathParts = metaFile.split('/');
const componentName = pathParts[pathParts.length - 1].replace('.js-meta.xml', '');
let masterLabel = componentName;
if (parsedXml.LightningComponentBundle &&
parsedXml.LightningComponentBundle.masterLabel &&
parsedXml.LightningComponentBundle.masterLabel.length > 0) {
masterLabel = parsedXml.LightningComponentBundle.masterLabel[0];
}
componentsInfo.push({
name: componentName,
label: masterLabel,
path: metaFile
});
}
catch (err) {
console.warn(`Could not parse meta file: ${metaFile}`, err.message);
}
}
componentsInfo.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));
const choices = componentsInfo.map(component => ({
value: component.name,
title: component.label,
description: `Name: ${component.name}`
}));
const componentSelectRes = await prompts({
type: 'select',
name: 'value',
message: 'Select a Lightning Web Component to extract custom labels from',
description: 'Choose which LWC component to analyze for custom label usage',
placeholder: 'Select a component',
choices: choices
});
return componentSelectRes.value;
}
catch (err) {
console.error('Error while finding LWC components:', err.message);
throw err;
}
}
static async promptExtractionMethod() {
try {
const methodSelectRes = await prompts({
type: 'select',
name: 'method',
message: 'How would you like to extract custom label translations?',
description: 'Choose your preferred method for extracting custom label translations',
placeholder: 'Select extraction method',
choices: [
{
value: 'labels',
title: 'Select specific custom labels',
description: 'Choose one or more custom labels from the full list'
},
{
value: 'lwc',
title: 'Extract from a Lightning Web Component',
description: 'Find all custom labels used in a specific LWC'
}
]
});
let values;
if (methodSelectRes.method === 'labels') {
values = await CustomLabelTranslations.promptCustomLabels();
}
else if (methodSelectRes.method === 'lwc') {
values = await CustomLabelTranslations.promptLwcComponent();
}
return {
type: methodSelectRes.method,
values: values
};
}
catch (err) {
console.error('Error during extraction method selection:', err.message);
throw err;
}
}
}
//# sourceMappingURL=custom-label-translations.js.map