diff --git a/package.json b/package.json index 2933d25..a389350 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "axios": "^1.7.9", "glob": "^10.4.5", "lwc": "~8.12.5", - "node-fetch": "^3.3.2" + "node-fetch": "^3.3.2", + "xml2js": "^0.6.2" }, "devDependencies": { "@oclif/plugin-command-snapshot": "^5.2.35", @@ -26,6 +27,7 @@ "@salesforce/dev-scripts": "^10.2.11", "@salesforce/plugin-command-reference": "^3.1.44", "@types/node-fetch": "^2.6.11", + "@types/xml2js": "^0.4.14", "eslint-plugin-sf-plugin": "^1.20.15", "esmock": "^2.6.9", "oclif": "^4.17.27", diff --git a/src/commands/lightning/dev/component.ts b/src/commands/lightning/dev/component.ts index 13482f3..47824a5 100644 --- a/src/commands/lightning/dev/component.ts +++ b/src/commands/lightning/dev/component.ts @@ -5,31 +5,16 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import fs from 'node:fs'; import path from 'node:path'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { Messages } from '@salesforce/core'; +import { Messages, SfProject } from '@salesforce/core'; import { cmpDev } from '@lwrjs/api'; +import { ComponentUtils } from '../../../shared/componentUtils.js'; import { PromptUtils } from '../../../shared/promptUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'lightning.dev.component'); -// TODO support other module directories -const MODULES_DIR = path.resolve(path.join('force-app', 'main', 'default', 'lwc')); - -function getDirectories(filePath: string): string[] { - try { - const items = fs.readdirSync(filePath); - - const directories = items.filter((item) => fs.statSync(path.join(filePath, item)).isDirectory()); - - return directories; - } catch (error) { - return []; - } -} - export default class LightningDevComponent extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); @@ -47,27 +32,35 @@ export default class LightningDevComponent extends SfCommand { }; public async run(): Promise { + const project = await SfProject.resolve(); const { flags } = await this.parse(LightningDevComponent); let name = flags.name; if (!name) { - const dirs = getDirectories(path.resolve(MODULES_DIR)); + const dirs = await ComponentUtils.getComponentPaths(project); if (!dirs) { throw new Error(messages.getMessage('error.directory')); } - const components = dirs.map((dir) => { - const xmlPath = path.resolve(path.join(MODULES_DIR, dir, `${dir}.js-meta.xml`)); - const xmlContent = fs.readFileSync(xmlPath, 'utf-8'); - const label = xmlContent.match(/(.*?)<\/masterLabel>/); - const description = xmlContent.match(/(.*?)<\/description>/); - - return { - name: dir, - label: label ? label[1] : '', - description: description ? description[1] : '', - }; - }); + const components = ( + await Promise.all( + dirs.map(async (dir) => { + const xml = await ComponentUtils.getComponentMetadata(dir); + if (!xml) { + return undefined; + } + + const componentName = path.basename(dir); + const label = ComponentUtils.componentNameToTitleCase(componentName); + + return { + name: componentName, + label: xml.LightningComponentBundle.masterLabel ?? label, + description: xml.LightningComponentBundle.description ?? '', + }; + }) + ) + ).filter((component) => !!component); name = await PromptUtils.promptUserToSelectComponent(components); if (!name) { diff --git a/src/shared/componentUtils.ts b/src/shared/componentUtils.ts new file mode 100644 index 0000000..61be659 --- /dev/null +++ b/src/shared/componentUtils.ts @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import path from 'node:path'; +import fs from 'node:fs'; +import { SfProject } from '@salesforce/core'; +import { glob } from 'glob'; +import { parseStringPromise } from 'xml2js'; + +export type LwcMetadata = { + LightningComponentBundle: { + description?: string; + masterLabel?: string; + }; +}; + +export class ComponentUtils { + public static componentNameToTitleCase(componentName: string): string { + if (!componentName) { + return ''; + } + + return componentName.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase()); + } + + public static async getComponentPaths(project: SfProject): Promise { + const packageDirs = project.getPackageDirectories(); + const namespacePaths = ( + await Promise.all(packageDirs.map((dir) => glob(`${dir.fullPath}/**/lwc`, { absolute: true }))) + ).flat(); + + return ( + await Promise.all( + namespacePaths.map(async (namespacePath): Promise => { + const children = await fs.promises.readdir(namespacePath, { withFileTypes: true }); + + return children + .filter((child) => child.isDirectory()) + .map((child) => path.join(child.parentPath, child.name)); + }) + ) + ).flat(); + } + + public static async getComponentMetadata(dirname: string): Promise { + const componentName = path.basename(dirname); + const metaXmlPath: string = path.join(dirname, `${componentName}.js-meta.xml`); + if (!fs.existsSync(metaXmlPath)) { + return undefined; + } + + const xmlContent: string = fs.readFileSync(metaXmlPath, 'utf8'); + const parsedData = (await parseStringPromise(xmlContent)) as LwcMetadata; + if (!this.isLwcMetadata(parsedData)) { + return undefined; + } + + return parsedData; + } + + private static isLwcMetadata(obj: unknown): obj is LwcMetadata { + return (obj && typeof obj === 'object' && 'LightningComponentBundle' in obj) === true; + } +} diff --git a/src/shared/promptUtils.ts b/src/shared/promptUtils.ts index 27ff93b..715428a 100644 --- a/src/shared/promptUtils.ts +++ b/src/shared/promptUtils.ts @@ -92,7 +92,7 @@ export class PromptUtils { public static async promptUserToSelectComponent(components: Array>): Promise { const choices = components.map((component) => ({ - name: component.label.length > 0 ? component.label : component.name, + name: component.label ?? component.name, value: component.name, description: component.description, })); diff --git a/test/shared/componentUtils.test.ts b/test/shared/componentUtils.test.ts new file mode 100644 index 0000000..f0d6658 --- /dev/null +++ b/test/shared/componentUtils.test.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { expect } from 'chai'; +import { ComponentUtils } from '../../src/shared/componentUtils.js'; + +describe('componentUtils', () => { + it('converts camel case component name to title case', () => { + expect(ComponentUtils.componentNameToTitleCase('myButton')).to.equal('My Button'); + expect(ComponentUtils.componentNameToTitleCase('myButtonGroup')).to.equal('My Button Group'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 768e69d..02c88f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4162,6 +4162,13 @@ resolved "https://registry.yarnpkg.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz#18b97a972f94f60a679fd5c796d96421b9abb9fd" integrity sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g== +"@types/xml2js@^0.4.14": + version "0.4.14" + resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.14.tgz#5d462a2a7330345e2309c6b549a183a376de8f9a" + integrity sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@^6.21.0": version "6.21.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz#30830c1ca81fd5f3c2714e524c4303e0194f9cd3"