diff --git a/src/cli/cli.ts b/src/cli/cli.ts index d3df4b37..890aa27e 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -2,6 +2,7 @@ import * as yargs from 'yargs'; import { red, green } from 'colorette'; import { ExtractTask } from './tasks/extract.task'; +import { LintTask } from './tasks/lint.task'; import { ParserInterface } from '../parsers/parser.interface'; import { PipeParser } from '../parsers/pipe.parser'; import { DirectiveParser } from '../parsers/directive.parser'; @@ -58,6 +59,12 @@ export const cli = y const paths = normalizePaths(output, parsed.patterns); return paths; }) + .option('lint', { + alias: 'lt', + describe: 'Tests if given output files containing all keys', + default: false, + type: 'boolean' + }) .option('format', { alias: 'f', describe: 'Format', @@ -98,6 +105,12 @@ export const cli = y type: 'boolean', conflicts: ['key-as-default-value', 'string-as-default-value'] }) + .option('lint', { + alias: 'lt', + describe: 'Tests if given output files containing all keys', + default: false, + type: 'boolean' + }) .option('string-as-default-value', { alias: 'd', describe: 'Use string as default value', @@ -117,13 +130,13 @@ export const cli = y .exitProcess(true) .parse(process.argv); -const extractTask = new ExtractTask(cli.input, cli.output, { +const task = cli.lint ? new LintTask(cli.input, cli.output) : new ExtractTask(cli.input, cli.output, { replace: cli.replace }); // Parsers const parsers: ParserInterface[] = [new PipeParser(), new DirectiveParser(), new ServiceParser(), new MarkerParser()]; -extractTask.setParsers(parsers); +task.setParsers(parsers); // Post processors const postProcessors: PostProcessorInterface[] = []; @@ -141,17 +154,17 @@ if (cli.keyAsDefaultValue) { if (cli.sort) { postProcessors.push(new SortByKeyPostProcessor()); } -extractTask.setPostProcessors(postProcessors); +task.setPostProcessors(postProcessors); // Compiler const compiler: CompilerInterface = CompilerFactory.create(cli.format, { indentation: cli.formatIndentation }); -extractTask.setCompiler(compiler); +task.setCompiler(compiler); // Run task try { - extractTask.execute(); + task.execute(); console.log(green('\nDone.\n')); console.log(donateMessage); process.exit(0); diff --git a/src/cli/tasks/core.task.ts b/src/cli/tasks/core.task.ts new file mode 100644 index 00000000..a87c8ef1 --- /dev/null +++ b/src/cli/tasks/core.task.ts @@ -0,0 +1,136 @@ +import { TranslationCollection } from '../../utils/translation.collection'; +import { TaskInterface } from './task.interface'; +import { ParserInterface } from '../../parsers/parser.interface'; +import { PostProcessorInterface } from '../../post-processors/post-processor.interface'; +import { CompilerInterface } from '../../compilers/compiler.interface'; + +import { bold, cyan, dim, green, red } from 'colorette'; +import * as glob from 'glob'; +import * as fs from 'fs'; +import * as path from 'path'; + +export abstract class CoreTask implements TaskInterface { + + protected parsers: ParserInterface[] = []; + protected postProcessors: PostProcessorInterface[] = []; + protected compiler: CompilerInterface; + + protected constructor(protected inputs: string[], protected outputs: string[]) { + this.inputs = inputs.map((input) => path.resolve(input)); + this.outputs = outputs.map((output) => path.resolve(output)); + } + + public execute(): void { + if (!this.compiler) { + throw new Error('No compiler configured'); + } + + this.printEnabledParsers(); + this.printEnabledPostProcessors(); + this.printEnabledCompiler(); + + this.out(bold('Extracting:')); + const extracted = this.extract(); + this.out(green(`\nFound %d strings.\n`), extracted.count()); + + this.executeTask(extracted); + } + + protected abstract executeTask(extracted: TranslationCollection): void; + + public setParsers(parsers: ParserInterface[]): this { + this.parsers = parsers; + return this; + } + + public setPostProcessors(postProcessors: PostProcessorInterface[]): this { + this.postProcessors = postProcessors; + return this; + } + + public setCompiler(compiler: CompilerInterface): this { + this.compiler = compiler; + return this; + } + + /** + * Extract strings from specified input dirs using configured parsers + */ + protected extract(): TranslationCollection { + let collection: TranslationCollection = new TranslationCollection(); + this.inputs.forEach((pattern) => { + this.getFiles(pattern).forEach((filePath) => { + this.out(dim('- %s'), filePath); + const contents: string = fs.readFileSync(filePath, 'utf-8'); + this.parsers.forEach((parser) => { + const extracted = parser.extract(contents, filePath); + if (extracted instanceof TranslationCollection) { + collection = collection.union(extracted); + } + }); + }); + }); + return collection; + } + + /** + * Get all files matching pattern + */ + protected getFiles(pattern: string): string[] { + return glob.sync(pattern).filter((filePath) => fs.statSync(filePath).isFile()); + } + + protected out(...args: any[]): void { + console.log.apply(this, arguments); + } + + protected printEnabledParsers(): void { + this.out(cyan('Enabled parsers:')); + if (this.parsers.length) { + this.out(cyan(dim(this.parsers.map((parser) => `- ${parser.constructor.name}`).join('\n')))); + } else { + this.out(cyan(dim('(none)'))); + } + this.out(); + } + + protected printEnabledPostProcessors(): void { + this.out(cyan('Enabled post processors:')); + if (this.postProcessors.length) { + this.out(cyan(dim(this.postProcessors.map((postProcessor) => `- ${postProcessor.constructor.name}`).join('\n')))); + } else { + this.out(cyan(dim('(none)'))); + } + this.out(); + } + + protected printEnabledCompiler(): void { + this.out(cyan('Compiler:')); + this.out(cyan(dim(`- ${this.compiler.constructor.name}`))); + this.out(); + } + + protected createOutputPath(output: string): string { + let dir: string = output; + let filename: string = `strings.${this.compiler.extension}`; + if (!fs.existsSync(output) || !fs.statSync(output).isDirectory()) { + dir = path.dirname(output); + filename = path.basename(output); + } + + return path.join(dir, filename); + } + + protected getExistingTranslationCollection(outputPath: string): TranslationCollection { + let existing: TranslationCollection = new TranslationCollection(); + if (fs.existsSync(outputPath)) { + try { + existing = this.compiler.parse(fs.readFileSync(outputPath, 'utf-8')); + } catch (e) { + this.out(`%s %s`, dim(`- ${outputPath}`), red(`[ERROR]`)); + throw e; + } + } + return existing; + } +} diff --git a/src/cli/tasks/extract.task.ts b/src/cli/tasks/extract.task.ts index 9c03c0ac..83a998a7 100644 --- a/src/cli/tasks/extract.task.ts +++ b/src/cli/tasks/extract.task.ts @@ -1,11 +1,10 @@ import { TranslationCollection } from '../../utils/translation.collection'; -import { TaskInterface } from './task.interface'; +import { CoreTask } from './core.task'; import { ParserInterface } from '../../parsers/parser.interface'; import { PostProcessorInterface } from '../../post-processors/post-processor.interface'; import { CompilerInterface } from '../../compilers/compiler.interface'; -import { cyan, green, bold, dim, red } from 'colorette'; -import * as glob from 'glob'; +import { bold, dim, green, red } from 'colorette'; import * as fs from 'fs'; import * as path from 'path'; import * as mkdirp from 'mkdirp'; @@ -14,7 +13,7 @@ export interface ExtractTaskOptionsInterface { replace?: boolean; } -export class ExtractTask implements TaskInterface { +export class ExtractTask extends CoreTask { protected options: ExtractTaskOptionsInterface = { replace: false }; @@ -24,45 +23,20 @@ export class ExtractTask implements TaskInterface { protected compiler: CompilerInterface; public constructor(protected inputs: string[], protected outputs: string[], options?: ExtractTaskOptionsInterface) { - this.inputs = inputs.map((input) => path.resolve(input)); - this.outputs = outputs.map((output) => path.resolve(output)); - this.options = { ...this.options, ...options }; + super(inputs, outputs); + this.options = {...this.options, ...options}; } - public execute(): void { - if (!this.compiler) { - throw new Error('No compiler configured'); - } - - this.printEnabledParsers(); - this.printEnabledPostProcessors(); - this.printEnabledCompiler(); - - this.out(bold('Extracting:')); - const extracted = this.extract(); - this.out(green(`\nFound %d strings.\n`), extracted.count()); - + /** + * Saves extracted translation keys in file(s) + */ + protected executeTask(extracted: TranslationCollection): void { this.out(bold('Saving:')); this.outputs.forEach((output) => { - let dir: string = output; - let filename: string = `strings.${this.compiler.extension}`; - if (!fs.existsSync(output) || !fs.statSync(output).isDirectory()) { - dir = path.dirname(output); - filename = path.basename(output); - } + const outputPath = this.createOutputPath(output); - const outputPath: string = path.join(dir, filename); - - let existing: TranslationCollection = new TranslationCollection(); - if (!this.options.replace && fs.existsSync(outputPath)) { - try { - existing = this.compiler.parse(fs.readFileSync(outputPath, 'utf-8')); - } catch (e) { - this.out(`%s %s`, dim(`- ${outputPath}`), red(`[ERROR]`)); - throw e; - } - } + const existing = this.options.replace ? new TranslationCollection() : this.getExistingTranslationCollection(outputPath); // merge extracted strings with existing const draft = extracted.union(existing); @@ -85,41 +59,6 @@ export class ExtractTask implements TaskInterface { }); } - public setParsers(parsers: ParserInterface[]): this { - this.parsers = parsers; - return this; - } - - public setPostProcessors(postProcessors: PostProcessorInterface[]): this { - this.postProcessors = postProcessors; - return this; - } - - public setCompiler(compiler: CompilerInterface): this { - this.compiler = compiler; - return this; - } - - /** - * Extract strings from specified input dirs using configured parsers - */ - protected extract(): TranslationCollection { - let collection: TranslationCollection = new TranslationCollection(); - this.inputs.forEach((pattern) => { - this.getFiles(pattern).forEach((filePath) => { - this.out(dim('- %s'), filePath); - const contents: string = fs.readFileSync(filePath, 'utf-8'); - this.parsers.forEach((parser) => { - const extracted = parser.extract(contents, filePath); - if (extracted instanceof TranslationCollection) { - collection = collection.union(extracted); - } - }); - }); - }); - return collection; - } - /** * Run strings through configured post processors */ @@ -132,6 +71,7 @@ export class ExtractTask implements TaskInterface { /** * Compile and save translations + * @param output * @param collection */ protected save(output: string, collection: TranslationCollection): void { @@ -141,41 +81,4 @@ export class ExtractTask implements TaskInterface { } fs.writeFileSync(output, this.compiler.compile(collection)); } - - /** - * Get all files matching pattern - */ - protected getFiles(pattern: string): string[] { - return glob.sync(pattern).filter((filePath) => fs.statSync(filePath).isFile()); - } - - protected out(...args: any[]): void { - console.log.apply(this, arguments); - } - - protected printEnabledParsers(): void { - this.out(cyan('Enabled parsers:')); - if (this.parsers.length) { - this.out(cyan(dim(this.parsers.map((parser) => `- ${parser.constructor.name}`).join('\n')))); - } else { - this.out(cyan(dim('(none)'))); - } - this.out(); - } - - protected printEnabledPostProcessors(): void { - this.out(cyan('Enabled post processors:')); - if (this.postProcessors.length) { - this.out(cyan(dim(this.postProcessors.map((postProcessor) => `- ${postProcessor.constructor.name}`).join('\n')))); - } else { - this.out(cyan(dim('(none)'))); - } - this.out(); - } - - protected printEnabledCompiler(): void { - this.out(cyan('Compiler:')); - this.out(cyan(dim(`- ${this.compiler.constructor.name}`))); - this.out(); - } } diff --git a/src/cli/tasks/lint.task.ts b/src/cli/tasks/lint.task.ts new file mode 100644 index 00000000..ac953b9f --- /dev/null +++ b/src/cli/tasks/lint.task.ts @@ -0,0 +1,48 @@ +import { TranslationCollection } from '../../utils/translation.collection'; +import { CoreTask } from './core.task'; +import { ParserInterface } from '../../parsers/parser.interface'; +import { PostProcessorInterface } from '../../post-processors/post-processor.interface'; +import { CompilerInterface } from '../../compilers/compiler.interface'; + +import { bold, dim, green, red } from 'colorette'; + +export class LintTask extends CoreTask { + + protected parsers: ParserInterface[] = []; + protected postProcessors: PostProcessorInterface[] = []; + protected compiler: CompilerInterface; + + public constructor(protected inputs: string[], protected outputs: string[]) { + super(inputs, outputs); + } + + /** + * Validates if all extracted translation keys matches the keys in file(s) + */ + public executeTask(extracted: TranslationCollection): void { + this.out(bold('Linting:')); + + let lintingValid: boolean = true; + this.outputs.forEach((output) => { + const outputPath = this.createOutputPath(output); + + const existing = this.getExistingTranslationCollection(outputPath); + + const lint = extracted.lintKeys(existing); + const hasNewKeys = lint.hasNewKeys(); + if (hasNewKeys) { + this.out(`%s %s`, dim(`- ${outputPath}`), red(`[INVALID KEYS]`), dim(lint.values.toString())); + } else { + this.out(`%s %s`, dim(`- ${outputPath}`), green(`[KEYS MATCHED]`)); + } + + if (hasNewKeys) { + lintingValid = false; + } + }); + + if (!lintingValid) { + throw new Error('Linting failed'); + } + } +} diff --git a/src/utils/translation.collection.ts b/src/utils/translation.collection.ts index b302e3b4..d6cfc3d9 100644 --- a/src/utils/translation.collection.ts +++ b/src/utils/translation.collection.ts @@ -2,6 +2,18 @@ export interface TranslationType { [key: string]: string; } +export class LintCollection { + public values: string[] = []; + + public constructor(values: string[] = []) { + this.values = values; + } + + public hasNewKeys(): boolean { + return this.values.length > 0; + } +} + export class TranslationCollection { public values: TranslationType = {}; @@ -90,4 +102,9 @@ export class TranslationCollection { return new TranslationCollection(values); } + + public lintKeys(collection: TranslationCollection): LintCollection { + const newKeys: string[] = this.keys().filter( key => !collection.has(key)); + return new LintCollection(newKeys); + } } diff --git a/tests/utils/translation.collection.spec.ts b/tests/utils/translation.collection.spec.ts index 05e1e321..d8e8be5a 100644 --- a/tests/utils/translation.collection.spec.ts +++ b/tests/utils/translation.collection.spec.ts @@ -96,4 +96,13 @@ describe('StringCollection', () => { blue: 'mapped value' }); }); + + it('should lint values', () => { + const extracted = new TranslationCollection({ key1: 'oldVal1', key2: 'oldVal2' }); + const existing = new TranslationCollection({ key1: 'newVal1', key3: 'newVal3' }); + const lintCollection = extracted.lintKeys(existing); + expect(lintCollection.values).to.deep.equal([ + 'key2' + ]); + }); });