diff --git a/README.md b/README.md index 715a7809..905f6d47 100644 --- a/README.md +++ b/README.md @@ -859,8 +859,8 @@ FLAG DESCRIPTIONS Sets the default language for Lightning Web Components in this project. When set to 'typescript', generates TypeScript configuration files (tsconfig.json, package.json with TypeScript dependencies, and TypeScript-aware - ESLint config). TypeScript projects compile locally to a dist/ folder for validation, but deploy raw .ts files to - Salesforce for server-side type stripping. Defaults to 'javascript'. + ESLint config). TypeScript files are compiled locally for validation, and the TypeScript (.ts) files are deployed + to Salesforce for server-side type stripping. Defaults to 'javascript'. ``` _See code: [src/commands/template/generate/project/index.ts](https://github.com/salesforcecli/plugin-templates/blob/56.14.0/src/commands/template/generate/project/index.ts)_ diff --git a/messages/lightning.md b/messages/lightning.md index e2b63904..9fa8be92 100644 --- a/messages/lightning.md +++ b/messages/lightning.md @@ -24,4 +24,4 @@ Template to use for file creation. # flags.template.description -Supplied parameter values or default values are filled into a copy of the template. +Supplied parameter values or default values are filled into a copy of the template. For Lightning Web Components, if not specified, the CLI automatically selects the template based on the project's sfdx-project.json "defaultLwcLanguage" field: TypeScript template for "typescript", JavaScript template for "javascript". diff --git a/messages/lightningCmp.md b/messages/lightningCmp.md index 977dff5d..2bb5bc3f 100644 --- a/messages/lightningCmp.md +++ b/messages/lightningCmp.md @@ -16,6 +16,10 @@ <%= config.bin %> <%= command.id %> --name mycomponent --type lwc --output-dir force-app/main/default/lwc +- Generate a TypeScript Lightning web component: + + <%= config.bin %> <%= command.id %> --name mycomponent --type lwc --template typescript + # summary Generate a bundle for an Aura component or a Lightning web component. diff --git a/messages/project.md b/messages/project.md index 6d1c7625..87cdcaf7 100644 --- a/messages/project.md +++ b/messages/project.md @@ -100,8 +100,8 @@ Normally defaults to https://login.salesforce.com. # flags.lwc-language.summary -Default language for Lightning Web Components. +Language of the Lightning Web Components. Default is "javascript". # flags.lwc-language.description -Sets the default language for Lightning Web Components in this project. When set to 'typescript', generates TypeScript configuration files (tsconfig.json, package.json with TypeScript dependencies, and TypeScript-aware ESLint config). TypeScript projects compile locally to a dist/ folder for validation, but deploy raw .ts files to Salesforce for server-side type stripping. Defaults to 'javascript'. +Sets the default language for Lightning Web Components in this project. When set to `'typescript'`, generates TypeScript configuration files (tsconfig.json, package.json with TypeScript dependencies, and TypeScript-aware ESLint config). TypeScript files are compiled locally for validation, and the TypeScript (`.ts`) files are deployed to Salesforce for server-side type stripping. If not specified, the project uses JavaScript. diff --git a/src/commands/template/generate/lightning/component.ts b/src/commands/template/generate/lightning/component.ts index 7b8af80e..69c066b9 100644 --- a/src/commands/template/generate/lightning/component.ts +++ b/src/commands/template/generate/lightning/component.ts @@ -9,7 +9,7 @@ import { Flags, loglevel, orgApiVersionFlagWithDeprecations, SfCommand, Ux } from '@salesforce/sf-plugins-core'; import { CreateOutput, LightningComponentOptions, TemplateType } from '@salesforce/templates'; -import { Messages } from '@salesforce/core'; +import { Messages, SfProject } from '@salesforce/core'; import { getCustomTemplates, runGenerator } from '../../../../utils/templateCommand.js'; import { internalFlag, outputDirFlagLightning } from '../../../../utils/flags.js'; const BUNDLE_TYPE = 'Component'; @@ -39,7 +39,7 @@ export default class LightningComponent extends SfCommand { default: 'default', // Note: keep this list here and LightningComponentOptions#template in-sync with the // templates/lightningcomponents/[aura|lwc]/* folders - options: ['default', 'analyticsDashboard', 'analyticsDashboardWithStep'] as const, + options: ['default', 'analyticsDashboard', 'analyticsDashboardWithStep', 'typescript'] as const, })(), 'output-dir': outputDirFlagLightning, 'api-version': orgApiVersionFlagWithDeprecations, @@ -54,9 +54,31 @@ export default class LightningComponent extends SfCommand { public async run(): Promise { const { flags } = await this.parse(LightningComponent); + + // Determine if user explicitly set the template flag + const userExplicitlySetTemplate = this.argv.includes('--template') || this.argv.includes('-t'); + let template = flags.template; + + // If template not explicitly provided and generating LWC, check project preference + if (!userExplicitlySetTemplate && flags.type === 'lwc') { + try { + const projectPath = flags['output-dir'] || process.cwd(); + const project = await SfProject.resolve(projectPath); + const projectJson = await project.resolveProjectConfig(); + const defaultLwcLanguage = projectJson.defaultLwcLanguage as string | undefined; + + if (defaultLwcLanguage === 'typescript') { + template = 'typescript'; + } + } catch (error) { + this.debug('Could not resolve project config for intelligent defaulting:', error); + } + } + const flagsAsOptions: LightningComponentOptions = { componentname: flags.name, - template: flags.template, + // Temp re-mapping to allow lowercase typescript flag + template: template === 'typescript' ? 'typeScript' : template, outputdir: flags['output-dir'], apiversion: flags['api-version'], internal: flags.internal, diff --git a/test/commands/template/generate/lightning/component.nut.ts b/test/commands/template/generate/lightning/component.nut.ts index 0773d63c..85692676 100644 --- a/test/commands/template/generate/lightning/component.nut.ts +++ b/test/commands/template/generate/lightning/component.nut.ts @@ -165,6 +165,166 @@ describe('template generate lightning component:', () => { }); }); + describe('TypeScript Lightning web component generation', () => { + it('should create TypeScript LWC with explicit template flag', () => { + execCmd( + 'template generate lightning component --componentname tsComponent --outputdir lwc --type lwc --template typescript', + { ensureExitCode: 0 } + ); + + // Verify TypeScript files exist + assert.file(path.join(session.project.dir, 'lwc', 'tsComponent', 'tsComponent.ts')); + assert.file(path.join(session.project.dir, 'lwc', 'tsComponent', 'tsComponent.html')); + assert.file(path.join(session.project.dir, 'lwc', 'tsComponent', '__tests__', 'tsComponent.test.ts')); + assert.file(path.join(session.project.dir, 'lwc', 'tsComponent', 'tsComponent.js-meta.xml')); + + // Verify no .js file in component folder + assert.noFile(path.join(session.project.dir, 'lwc', 'tsComponent', 'tsComponent.js')); + + // Verify TypeScript content + assert.fileContent( + path.join(session.project.dir, 'lwc', 'tsComponent', 'tsComponent.ts'), + 'export default class TsComponent extends LightningElement {}' + ); + + // Verify TypeScript test content + assert.fileContent( + path.join(session.project.dir, 'lwc', 'tsComponent', '__tests__', 'tsComponent.test.ts'), + "import TsComponent from 'c/tsComponent';" + ); + }); + + it('should automatically use TypeScript template in TypeScript project', () => { + // Create a TypeScript project + execCmd('template generate project --name tsProject --lwc-language typescript', { + ensureExitCode: 0, + }); + + // Generate component without specifying template + execCmd( + 'template generate lightning component --componentname autoTs --outputdir tsProject/force-app/main/default/lwc --type lwc', + { ensureExitCode: 0 } + ); + + // Verify TypeScript files were created + assert.file( + path.join(session.project.dir, 'tsProject', 'force-app', 'main', 'default', 'lwc', 'autoTs', 'autoTs.ts') + ); + assert.file( + path.join( + session.project.dir, + 'tsProject', + 'force-app', + 'main', + 'default', + 'lwc', + 'autoTs', + '__tests__', + 'autoTs.test.ts' + ) + ); + + // Verify no .js file + assert.noFile( + path.join(session.project.dir, 'tsProject', 'force-app', 'main', 'default', 'lwc', 'autoTs', 'autoTs.js') + ); + }); + + it('should use JavaScript template in JavaScript project', () => { + // Create a JavaScript project + execCmd('template generate project --name jsProject --lwc-language javascript', { + ensureExitCode: 0, + }); + + // Generate component without specifying template + execCmd( + 'template generate lightning component --componentname autoJs --outputdir jsProject/force-app/main/default/lwc --type lwc', + { ensureExitCode: 0 } + ); + + // Verify JavaScript files were created + assert.file( + path.join(session.project.dir, 'jsProject', 'force-app', 'main', 'default', 'lwc', 'autoJs', 'autoJs.js') + ); + assert.file( + path.join( + session.project.dir, + 'jsProject', + 'force-app', + 'main', + 'default', + 'lwc', + 'autoJs', + '__tests__', + 'autoJs.test.js' + ) + ); + + // Verify no .ts file + assert.noFile( + path.join(session.project.dir, 'jsProject', 'force-app', 'main', 'default', 'lwc', 'autoJs', 'autoJs.ts') + ); + }); + + it('should allow explicit template override in TypeScript project', () => { + // Create a TypeScript project + execCmd('template generate project --name tsOverrideProject --lwc-language typescript', { + ensureExitCode: 0, + }); + + // Generate JavaScript component explicitly in TypeScript project + execCmd( + 'template generate lightning component --componentname jsInTs --outputdir tsOverrideProject/force-app/main/default/lwc --type lwc --template default', + { ensureExitCode: 0 } + ); + + // Verify JavaScript files were created (override worked) + assert.file( + path.join( + session.project.dir, + 'tsOverrideProject', + 'force-app', + 'main', + 'default', + 'lwc', + 'jsInTs', + 'jsInTs.js' + ) + ); + assert.noFile( + path.join( + session.project.dir, + 'tsOverrideProject', + 'force-app', + 'main', + 'default', + 'lwc', + 'jsInTs', + 'jsInTs.ts' + ) + ); + }); + + it('should create TypeScript component with proper class naming', () => { + execCmd( + 'template generate lightning component --componentname mySpecialComponent --outputdir lwc --type lwc --template typescript', + { ensureExitCode: 0 } + ); + + // Verify PascalCase class name + assert.fileContent( + path.join(session.project.dir, 'lwc', 'mySpecialComponent', 'mySpecialComponent.ts'), + 'export default class MySpecialComponent extends LightningElement {}' + ); + + // Verify test imports use camelCase + assert.fileContent( + path.join(session.project.dir, 'lwc', 'mySpecialComponent', '__tests__', 'mySpecialComponent.test.ts'), + "import MySpecialComponent from 'c/mySpecialComponent';" + ); + }); + }); + describe('lightning component failures', () => { it('should throw missing component name error', () => { const stderr = execCmd('template generate lightning component --outputdir aura').shellOutput.stderr; @@ -196,5 +356,46 @@ describe('template generate lightning component:', () => { messages.getMessage('MissingLightningComponentTemplate', ['analyticsDashboard', 'aura']) ); }); + + it('should throw error when using typescript template with aura type', () => { + const stderr = execCmd( + 'template generate lightning component --outputdir aura --componentname foo --type aura --template typescript' + ).shellOutput.stderr; + expect(stderr).to.contain(messages.getMessage('MissingLightningComponentTemplate', ['typeScript', 'aura'])); + }); + }); + + describe('Component generation outside project context', () => { + it('should create JavaScript component outside project with no template flag', () => { + // Generate component in a directory without sfdx-project.json + execCmd( + 'template generate lightning component --componentname outsideComponent --outputdir standalone/lwc --type lwc', + { + ensureExitCode: 0, + } + ); + + // Verify JavaScript files were created (default when no project context) + assert.file(path.join(session.project.dir, 'standalone', 'lwc', 'outsideComponent', 'outsideComponent.js')); + assert.file(path.join(session.project.dir, 'standalone', 'lwc', 'outsideComponent', 'outsideComponent.html')); + + // Verify no TypeScript file + assert.noFile(path.join(session.project.dir, 'standalone', 'lwc', 'outsideComponent', 'outsideComponent.ts')); + }); + + it('should create TypeScript component outside project with explicit template flag', () => { + // Generate TypeScript component outside project + execCmd( + 'template generate lightning component --componentname outsideTsComponent --outputdir standalone/lwc --type lwc --template typescript', + { ensureExitCode: 0 } + ); + + // Verify TypeScript files were created + assert.file(path.join(session.project.dir, 'standalone', 'lwc', 'outsideTsComponent', 'outsideTsComponent.ts')); + assert.file(path.join(session.project.dir, 'standalone', 'lwc', 'outsideTsComponent', 'outsideTsComponent.html')); + + // Verify no JavaScript file + assert.noFile(path.join(session.project.dir, 'standalone', 'lwc', 'outsideTsComponent', 'outsideTsComponent.js')); + }); }); }); diff --git a/test/commands/template/generate/project/index.nut.ts b/test/commands/template/generate/project/index.nut.ts index 08434302..4910cea6 100644 --- a/test/commands/template/generate/project/index.nut.ts +++ b/test/commands/template/generate/project/index.nut.ts @@ -368,7 +368,7 @@ describe('template generate project:', () => { assert.fileContent(tsconfigPath, '**/__tests__/**'); }); - it('should verify .forceignore excludes dist/ folder', () => { + it('should verify .forceignore excludes TypeScript configuration files', () => { execCmd('template generate project --projectname forceignore-test --lwc-language typescript', { ensureExitCode: 0, }); @@ -454,6 +454,45 @@ describe('template generate project:', () => { const sfdxContent = fs.readFileSync(sfdxProjectPath, 'utf8'); expect(sfdxContent).to.not.contain('defaultLwcLanguage'); }); + + it('should create TypeScript project with empty template including full toolchain', () => { + execCmd('template generate project --projectname empty-ts --template empty --lwc-language typescript', { + ensureExitCode: 0, + }); + + const projectDir = path.join(session.project.dir, 'empty-ts'); + + // Verify TypeScript-specific files exist + assert.file([path.join(projectDir, 'tsconfig.json')]); + assert.file([path.join(projectDir, 'package.json')]); + assert.file([path.join(projectDir, 'eslint.config.js')]); + assert.file([path.join(projectDir, '.forceignore')]); + assert.file([path.join(projectDir, '.gitignore')]); + + // Verify Husky hooks exist for empty template + for (const file of huskyhookarray) { + assert.file([path.join(projectDir, '.husky', file)]); + } + + // Verify VSCode config files exist + for (const file of vscodearray) { + assert.file([path.join(projectDir, '.vscode', `${file}.json`)]); + } + + // Verify sfdx-project.json includes defaultLwcLanguage + const sfdxProjectPath = path.join(projectDir, 'sfdx-project.json'); + assert.fileContent(sfdxProjectPath, '"defaultLwcLanguage": "typescript"'); + + // Verify package.json has TypeScript dependencies + const packageJsonPath = path.join(projectDir, 'package.json'); + assert.fileContent(packageJsonPath, '"typescript"'); + assert.fileContent(packageJsonPath, '"build": "tsc"'); + + // Verify empty template folders exist + for (const folder of emptyfolderarray) { + assert(fs.existsSync(path.join(projectDir, 'force-app', 'main', 'default', folder))); + } + }); }); describe('project creation failures', () => { @@ -466,5 +505,11 @@ describe('template generate project:', () => { const stderr = execCmd('template generate project --projectname foo --template foo').shellOutput.stderr; expect(stderr).to.contain(messages.getMessage('InvalidTemplate')); }); + + it('should throw error for invalid lwc-language value', () => { + const stderr = execCmd('template generate project --projectname foo --lwc-language python').shellOutput.stderr; + expect(stderr).to.contain('Expected --lwc-language'); + expect(stderr).to.match(/(javascript|typescript)/); + }); }); });