|
| 1 | +/* |
| 2 | + * Copyright (c) 2025, Salesforce, Inc. |
| 3 | + * SPDX-License-Identifier: Apache-2 |
| 4 | + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 |
| 5 | + */ |
| 6 | +import fs from 'node:fs'; |
| 7 | +import path from 'node:path'; |
| 8 | +import {Args, Flags, ux} from '@oclif/core'; |
| 9 | +import {glob} from 'glob'; |
| 10 | +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; |
| 11 | +import { |
| 12 | + CONTENT_SCHEMA_TYPES, |
| 13 | + MetaDefinitionDetectionError, |
| 14 | + validateMetaDefinitionFile, |
| 15 | + type ContentSchemaType, |
| 16 | + type MetaDefinitionValidationResult, |
| 17 | +} from '@salesforce/b2c-tooling-sdk/operations/content'; |
| 18 | + |
| 19 | +interface ContentValidateResponse { |
| 20 | + results: MetaDefinitionValidationResult[]; |
| 21 | + totalErrors: number; |
| 22 | + totalFiles: number; |
| 23 | + validFiles: number; |
| 24 | +} |
| 25 | + |
| 26 | +export default class ContentValidate extends BaseCommand<typeof ContentValidate> { |
| 27 | + static args = { |
| 28 | + files: Args.string({ |
| 29 | + description: 'File(s), directory, or glob pattern(s) for JSON metadefinition files to validate', |
| 30 | + required: true, |
| 31 | + }), |
| 32 | + }; |
| 33 | + |
| 34 | + static description = 'Validate Page Designer metadefinition JSON files against schemas'; |
| 35 | + |
| 36 | + static enableJsonFlag = true; |
| 37 | + |
| 38 | + static examples = [ |
| 39 | + '<%= config.bin %> <%= command.id %> cartridge/experience/pages/storePage.json', |
| 40 | + '<%= config.bin %> <%= command.id %> --type componenttype mycomponent.json', |
| 41 | + "<%= config.bin %> <%= command.id %> 'cartridge/experience/**/*.json'", |
| 42 | + '<%= config.bin %> <%= command.id %> cartridge/experience/', |
| 43 | + '<%= config.bin %> <%= command.id %> storePage.json --json', |
| 44 | + ]; |
| 45 | + |
| 46 | + static flags = { |
| 47 | + ...BaseCommand.baseFlags, |
| 48 | + type: Flags.string({ |
| 49 | + char: 't', |
| 50 | + description: 'Schema type (auto-detected if not specified)', |
| 51 | + options: [...CONTENT_SCHEMA_TYPES], |
| 52 | + }), |
| 53 | + }; |
| 54 | + |
| 55 | + // Allow multiple file arguments |
| 56 | + static strict = false; |
| 57 | + |
| 58 | + protected operations: { |
| 59 | + glob: (pattern: string, options?: {nodir?: boolean}) => Promise<string[]>; |
| 60 | + validateMetaDefinitionFile: typeof validateMetaDefinitionFile; |
| 61 | + } = { |
| 62 | + glob, |
| 63 | + validateMetaDefinitionFile, |
| 64 | + }; |
| 65 | + |
| 66 | + async run(): Promise<ContentValidateResponse> { |
| 67 | + const {argv, flags} = await this.parse(ContentValidate); |
| 68 | + const patterns = argv as string[]; |
| 69 | + |
| 70 | + if (patterns.length === 0) { |
| 71 | + this.error('At least one file path, directory, or glob pattern is required.'); |
| 72 | + } |
| 73 | + |
| 74 | + // Expand directories to recursive JSON globs, then resolve all patterns in parallel |
| 75 | + const resolvedPatterns = patterns.map((pattern) => |
| 76 | + fs.existsSync(pattern) && fs.statSync(pattern).isDirectory() ? path.join(pattern, '**/*.json') : pattern, |
| 77 | + ); |
| 78 | + const allMatches = await Promise.all( |
| 79 | + resolvedPatterns.map((resolved) => this.operations.glob(resolved, {nodir: true})), |
| 80 | + ); |
| 81 | + const filePaths: string[] = []; |
| 82 | + for (const [i, matches] of allMatches.entries()) { |
| 83 | + if (matches.length === 0) { |
| 84 | + this.warn(`No files matched: ${patterns[i]}`); |
| 85 | + } |
| 86 | + filePaths.push(...matches); |
| 87 | + } |
| 88 | + |
| 89 | + if (filePaths.length === 0) { |
| 90 | + this.error('No files found matching the provided patterns.'); |
| 91 | + } |
| 92 | + |
| 93 | + const results: MetaDefinitionValidationResult[] = []; |
| 94 | + |
| 95 | + for (const filePath of filePaths) { |
| 96 | + try { |
| 97 | + const result = this.operations.validateMetaDefinitionFile(filePath, { |
| 98 | + type: flags.type as ContentSchemaType | undefined, |
| 99 | + }); |
| 100 | + results.push(result); |
| 101 | + } catch (error) { |
| 102 | + if (error instanceof MetaDefinitionDetectionError) { |
| 103 | + const relativePath = path.relative(process.cwd(), path.resolve(filePath)); |
| 104 | + this.error(`${relativePath}: ${error.message}`); |
| 105 | + } |
| 106 | + throw error; |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0); |
| 111 | + const validFiles = results.filter((r) => r.valid).length; |
| 112 | + |
| 113 | + const response: ContentValidateResponse = { |
| 114 | + results, |
| 115 | + totalErrors, |
| 116 | + totalFiles: results.length, |
| 117 | + validFiles, |
| 118 | + }; |
| 119 | + |
| 120 | + if (this.jsonEnabled()) { |
| 121 | + return response; |
| 122 | + } |
| 123 | + |
| 124 | + for (const result of results) { |
| 125 | + const relativePath = path.relative(process.cwd(), result.filePath ?? ''); |
| 126 | + const typeInfo = result.schemaType ? ` (${result.schemaType})` : ''; |
| 127 | + |
| 128 | + if (result.valid) { |
| 129 | + ux.stdout(`${ux.colorize('green', 'PASS')}: ${relativePath}${typeInfo}`); |
| 130 | + } else { |
| 131 | + ux.stdout(`${ux.colorize('red', 'FAIL')}: ${relativePath}${typeInfo}`); |
| 132 | + for (const error of result.errors) { |
| 133 | + const location = error.path && error.path !== '/' ? ` at ${error.path}` : ''; |
| 134 | + ux.stdout(` ${ux.colorize('red', 'ERROR')}${location}: ${error.message}`); |
| 135 | + } |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + ux.stdout(''); |
| 140 | + ux.stdout(`${validFiles}/${results.length} file(s) valid, ${totalErrors} error(s)`); |
| 141 | + |
| 142 | + if (totalErrors > 0) { |
| 143 | + this.error('Validation failed', {exit: 1}); |
| 144 | + } |
| 145 | + |
| 146 | + return response; |
| 147 | + } |
| 148 | +} |
0 commit comments