- (chore) update packages
- (refactor) use tsquery for querying AST - (feat) autodetect usage of marker function and remove --marker cli argument - (bugfix) extract strings when TranslateService is declared directly as a class parameter. Closes https://github.com/biesbjerg/ngx-translate-extract/issues/83 - (bugfix) handle split strings: marker('hello ' + 'world') is now extracted as a single string: 'hello world'
This commit is contained in:
@@ -6,7 +6,7 @@ import { ParserInterface } from '../parsers/parser.interface';
|
||||
import { PipeParser } from '../parsers/pipe.parser';
|
||||
import { DirectiveParser } from '../parsers/directive.parser';
|
||||
import { ServiceParser } from '../parsers/service.parser';
|
||||
import { FunctionParser } from '../parsers/function.parser';
|
||||
import { MarkerParser } from '../parsers/marker.parser';
|
||||
import { PostProcessorInterface } from '../post-processors/post-processor.interface';
|
||||
import { SortByKeyPostProcessor } from '../post-processors/sort-by-key.post-processor';
|
||||
import { KeyAsDefaultValuePostProcessor } from '../post-processors/key-as-default-value.post-processor';
|
||||
@@ -29,7 +29,7 @@ export const cli = yargs
|
||||
normalize: true
|
||||
})
|
||||
.check(options => {
|
||||
options.input.forEach((dir: string) => {
|
||||
(options.input as unknown as string[]).forEach((dir: string) => {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
throw new Error(`The path you supplied was not found: '${dir}'`);
|
||||
}
|
||||
@@ -50,12 +50,6 @@ export const cli = yargs
|
||||
normalize: true,
|
||||
required: true
|
||||
})
|
||||
.option('marker', {
|
||||
alias: 'm',
|
||||
describe: 'Extract strings passed to a marker function',
|
||||
default: false,
|
||||
type: 'string'
|
||||
})
|
||||
.option('format', {
|
||||
alias: 'f',
|
||||
describe: 'Output format',
|
||||
@@ -96,7 +90,7 @@ export const cli = yargs
|
||||
.exitProcess(true)
|
||||
.parse(process.argv);
|
||||
|
||||
const extractTask = new ExtractTask(cli.input, cli.output, {
|
||||
const extractTask = new ExtractTask(cli.input as unknown as string[], cli.output, {
|
||||
replace: cli.replace,
|
||||
patterns: cli.patterns
|
||||
});
|
||||
@@ -105,13 +99,9 @@ const extractTask = new ExtractTask(cli.input, cli.output, {
|
||||
const parsers: ParserInterface[] = [
|
||||
new PipeParser(),
|
||||
new DirectiveParser(),
|
||||
new ServiceParser()
|
||||
new ServiceParser(),
|
||||
new MarkerParser()
|
||||
];
|
||||
if (cli.marker) {
|
||||
parsers.push(new FunctionParser({
|
||||
identifier: cli.marker
|
||||
}));
|
||||
}
|
||||
extractTask.setParsers(parsers);
|
||||
|
||||
// Post processors
|
||||
|
@@ -100,17 +100,20 @@ export class ExtractTask implements TaskInterface {
|
||||
* Extract strings from specified input dirs using configured parsers
|
||||
*/
|
||||
protected extract(): TranslationCollection {
|
||||
let extracted: TranslationCollection = new TranslationCollection();
|
||||
let collection: TranslationCollection = new TranslationCollection();
|
||||
this.inputs.forEach(dir => {
|
||||
this.readDir(dir, this.options.patterns).forEach(path => {
|
||||
this.out(dim('- %s'), path);
|
||||
const contents: string = fs.readFileSync(path, 'utf-8');
|
||||
this.parsers.forEach(parser => {
|
||||
extracted = extracted.union(parser.extract(contents, path));
|
||||
const extracted = parser.extract(contents, path);
|
||||
if (extracted) {
|
||||
collection = collection.union(extracted);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return extracted;
|
||||
return collection;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -6,11 +6,10 @@ export * from './cli/tasks/task.interface';
|
||||
export * from './cli/tasks/extract.task';
|
||||
|
||||
export * from './parsers/parser.interface';
|
||||
export * from './parsers/abstract-ast.parser';
|
||||
export * from './parsers/directive.parser';
|
||||
export * from './parsers/pipe.parser';
|
||||
export * from './parsers/service.parser';
|
||||
export * from './parsers/function.parser';
|
||||
export * from './parsers/marker.parser';
|
||||
|
||||
export * from './compilers/compiler.interface';
|
||||
export * from './compilers/compiler.factory';
|
||||
|
@@ -1,48 +0,0 @@
|
||||
import {
|
||||
createSourceFile,
|
||||
SourceFile,
|
||||
CallExpression,
|
||||
Node,
|
||||
SyntaxKind,
|
||||
StringLiteral,
|
||||
NoSubstitutionTemplateLiteral
|
||||
} from 'typescript';
|
||||
|
||||
export abstract class AbstractAstParser {
|
||||
|
||||
protected sourceFile: SourceFile;
|
||||
|
||||
protected createSourceFile(path: string, contents: string): SourceFile {
|
||||
return createSourceFile(path, contents, null, /*setParentNodes */ false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strings from function call's first argument
|
||||
*/
|
||||
protected getStringLiterals(callNode: CallExpression): string[] {
|
||||
if (!callNode.arguments.length) {
|
||||
return[];
|
||||
}
|
||||
|
||||
const firstArg = callNode.arguments[0];
|
||||
|
||||
return this.findNodes(firstArg, [
|
||||
SyntaxKind.StringLiteral,
|
||||
SyntaxKind.NoSubstitutionTemplateLiteral
|
||||
])
|
||||
.map((node: StringLiteral | NoSubstitutionTemplateLiteral) => node.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all child nodes of a kind
|
||||
*/
|
||||
protected findNodes(node: Node, kinds: SyntaxKind[]): Node[] {
|
||||
const childrenNodes: Node[] = node.getChildren(this.sourceFile);
|
||||
const initialValue: Node[] = kinds.includes(node.kind) ? [node] : [];
|
||||
|
||||
return childrenNodes.reduce((result: Node[], childNode: Node) => {
|
||||
return result.concat(this.findNodes(childNode, kinds));
|
||||
}, initialValue);
|
||||
}
|
||||
|
||||
}
|
@@ -1,60 +0,0 @@
|
||||
import { Node, CallExpression, SyntaxKind, Identifier } from 'typescript';
|
||||
|
||||
import { ParserInterface } from './parser.interface';
|
||||
import { AbstractAstParser } from './abstract-ast.parser';
|
||||
import { TranslationCollection } from '../utils/translation.collection';
|
||||
|
||||
export class FunctionParser extends AbstractAstParser implements ParserInterface {
|
||||
|
||||
protected functionIdentifier: string = 'marker';
|
||||
|
||||
public constructor(options?: any) {
|
||||
super();
|
||||
if (options && typeof options.identifier !== 'undefined') {
|
||||
this.functionIdentifier = options.identifier;
|
||||
}
|
||||
}
|
||||
|
||||
public extract(template: string, path: string): TranslationCollection {
|
||||
let collection: TranslationCollection = new TranslationCollection();
|
||||
|
||||
this.sourceFile = this.createSourceFile(path, template);
|
||||
|
||||
const callNodes = this.findCallNodes();
|
||||
callNodes.forEach(callNode => {
|
||||
const keys: string[] = this.getStringLiterals(callNode);
|
||||
if (keys && keys.length) {
|
||||
collection = collection.addKeys(keys);
|
||||
}
|
||||
});
|
||||
return collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all calls to marker function
|
||||
*/
|
||||
protected findCallNodes(node?: Node): CallExpression[] {
|
||||
if (!node) {
|
||||
node = this.sourceFile;
|
||||
}
|
||||
|
||||
let callNodes = this.findNodes(node, [SyntaxKind.CallExpression]) as CallExpression[];
|
||||
callNodes = callNodes
|
||||
.filter(callNode => {
|
||||
// Only call expressions with arguments
|
||||
if (callNode.arguments.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const identifier = (callNode.getChildAt(0) as Identifier).text;
|
||||
if (identifier !== this.functionIdentifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return callNodes;
|
||||
}
|
||||
|
||||
}
|
34
src/parsers/marker.parser.ts
Normal file
34
src/parsers/marker.parser.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { tsquery } from '@phenomnomnominal/tsquery';
|
||||
|
||||
import { ParserInterface } from './parser.interface';
|
||||
import { TranslationCollection } from '../utils/translation.collection';
|
||||
import { getNamedImportAlias, findFunctionCallExpressions, getStringsFromExpression } from '../utils/ast-helpers';
|
||||
|
||||
const MARKER_PACKAGE_MODULE_NAME = '@biesbjerg/ngx-translate-extract-marker';
|
||||
const MARKER_PACKAGE_IMPORT_NAME = 'marker';
|
||||
|
||||
export class MarkerParser implements ParserInterface {
|
||||
|
||||
public extract(contents: string, filePath: string): TranslationCollection {
|
||||
const sourceFile = tsquery.ast(contents, filePath);
|
||||
|
||||
const markerFnName = getNamedImportAlias(sourceFile, MARKER_PACKAGE_MODULE_NAME, MARKER_PACKAGE_IMPORT_NAME);
|
||||
if (!markerFnName) {
|
||||
return;
|
||||
}
|
||||
|
||||
let collection: TranslationCollection = new TranslationCollection();
|
||||
|
||||
const callNodes = findFunctionCallExpressions(sourceFile, markerFnName);
|
||||
callNodes.forEach(callNode => {
|
||||
const [firstArgNode] = callNode.arguments;
|
||||
if (!firstArgNode) {
|
||||
return;
|
||||
}
|
||||
const strings = getStringsFromExpression(firstArgNode);
|
||||
collection = collection.addKeys(strings);
|
||||
});
|
||||
return collection;
|
||||
}
|
||||
|
||||
}
|
@@ -1,142 +1,41 @@
|
||||
import {
|
||||
SourceFile,
|
||||
Node,
|
||||
ConstructorDeclaration,
|
||||
Identifier,
|
||||
TypeReferenceNode,
|
||||
ClassDeclaration,
|
||||
SyntaxKind,
|
||||
CallExpression,
|
||||
PropertyAccessExpression,
|
||||
isPropertyAccessExpression
|
||||
} from 'typescript';
|
||||
import { tsquery } from '@phenomnomnominal/tsquery';
|
||||
|
||||
import { ParserInterface } from './parser.interface';
|
||||
import { AbstractAstParser } from './abstract-ast.parser';
|
||||
import { TranslationCollection } from '../utils/translation.collection';
|
||||
import { findClasses, findClassPropertyByType, findMethodCallExpression, getStringsFromExpression } from '../utils/ast-helpers';
|
||||
|
||||
export class ServiceParser extends AbstractAstParser implements ParserInterface {
|
||||
const TRANSLATE_SERVICE_TYPE_REFERENCE = 'TranslateService';
|
||||
const TRANSLATE_SERVICE_METHOD_NAMES = ['get', 'instant', 'stream'];
|
||||
|
||||
protected sourceFile: SourceFile;
|
||||
export class ServiceParser implements ParserInterface {
|
||||
|
||||
public extract(source: string, filePath: string): TranslationCollection {
|
||||
const sourceFile = tsquery.ast(source, filePath);
|
||||
|
||||
const classNodes = findClasses(sourceFile);
|
||||
if (!classNodes) {
|
||||
return;
|
||||
}
|
||||
|
||||
public extract(template: string, path: string): TranslationCollection {
|
||||
let collection: TranslationCollection = new TranslationCollection();
|
||||
|
||||
this.sourceFile = this.createSourceFile(path, template);
|
||||
const classNodes = this.findClassNodes(this.sourceFile);
|
||||
classNodes.forEach(classNode => {
|
||||
const constructorNode = this.findConstructorNode(classNode);
|
||||
if (!constructorNode) {
|
||||
const propName: string = findClassPropertyByType(classNode, TRANSLATE_SERVICE_TYPE_REFERENCE);
|
||||
if (!propName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertyName: string = this.findTranslateServicePropertyName(constructorNode);
|
||||
if (!propertyName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callNodes = this.findCallNodes(classNode, propertyName);
|
||||
const callNodes = findMethodCallExpression(classNode, propName, TRANSLATE_SERVICE_METHOD_NAMES);
|
||||
callNodes.forEach(callNode => {
|
||||
const keys: string[] = this.getStringLiterals(callNode);
|
||||
if (keys && keys.length) {
|
||||
collection = collection.addKeys(keys);
|
||||
const [firstArgNode] = callNode.arguments;
|
||||
if (!firstArgNode) {
|
||||
return;
|
||||
}
|
||||
const strings = getStringsFromExpression(firstArgNode);
|
||||
collection = collection.addKeys(strings);
|
||||
});
|
||||
});
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect what the TranslateService instance property
|
||||
* is called by inspecting constructor arguments
|
||||
*/
|
||||
protected findTranslateServicePropertyName(constructorNode: ConstructorDeclaration): string {
|
||||
if (!constructorNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = constructorNode.parameters.find(parameter => {
|
||||
// Skip if visibility modifier is not present (we want it set as an instance property)
|
||||
if (!parameter.modifiers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parameter has no type
|
||||
if (!parameter.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure className is of the correct type
|
||||
const parameterType: Identifier = (parameter.type as TypeReferenceNode).typeName as Identifier;
|
||||
if (!parameterType) {
|
||||
return false;
|
||||
}
|
||||
const className: string = parameterType.text;
|
||||
if (className !== 'TranslateService') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (result) {
|
||||
return (result.name as Identifier).text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find class nodes
|
||||
*/
|
||||
protected findClassNodes(node: Node): ClassDeclaration[] {
|
||||
return this.findNodes(node, [SyntaxKind.ClassDeclaration]) as ClassDeclaration[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find constructor
|
||||
*/
|
||||
protected findConstructorNode(node: ClassDeclaration): ConstructorDeclaration {
|
||||
const constructorNodes = this.findNodes(node, [SyntaxKind.Constructor]) as ConstructorDeclaration[];
|
||||
if (constructorNodes) {
|
||||
return constructorNodes[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all calls to TranslateService methods
|
||||
*/
|
||||
protected findCallNodes(node: Node, propertyIdentifier: string): CallExpression[] {
|
||||
let callNodes = this.findNodes(node, [SyntaxKind.CallExpression]) as CallExpression[];
|
||||
callNodes = callNodes
|
||||
.filter(callNode => {
|
||||
// Only call expressions with arguments
|
||||
if (callNode.arguments.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const propAccess = callNode.getChildAt(0).getChildAt(0) as PropertyAccessExpression;
|
||||
if (!propAccess || !isPropertyAccessExpression(propAccess)) {
|
||||
return false;
|
||||
}
|
||||
if (!propAccess.getFirstToken() || propAccess.getFirstToken().kind !== SyntaxKind.ThisKeyword) {
|
||||
return false;
|
||||
}
|
||||
if (propAccess.name.text !== propertyIdentifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const methodAccess = callNode.getChildAt(0) as PropertyAccessExpression;
|
||||
if (!methodAccess || methodAccess.kind !== SyntaxKind.PropertyAccessExpression) {
|
||||
return false;
|
||||
}
|
||||
if (!methodAccess.name || (methodAccess.name.text !== 'get' && methodAccess.name.text !== 'instant' && methodAccess.name.text !== 'stream')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return callNodes;
|
||||
}
|
||||
|
||||
}
|
||||
|
135
src/utils/ast-helpers.ts
Normal file
135
src/utils/ast-helpers.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { tsquery } from '@phenomnomnominal/tsquery';
|
||||
import {
|
||||
Node,
|
||||
NamedImports,
|
||||
Identifier,
|
||||
ClassDeclaration,
|
||||
CallExpression,
|
||||
isStringLiteralLike,
|
||||
isArrayLiteralExpression,
|
||||
Expression,
|
||||
isBinaryExpression,
|
||||
SyntaxKind,
|
||||
isConditionalExpression,
|
||||
PropertyAccessExpression
|
||||
} from 'typescript';
|
||||
|
||||
export function getNamedImports(node: Node, moduleName: string): NamedImports[] {
|
||||
const query = `ImportDeclaration[moduleSpecifier.text="${moduleName}"] NamedImports`;
|
||||
return tsquery<NamedImports>(node, query);
|
||||
}
|
||||
|
||||
export function getNamedImportAlias(node: Node, moduleName: string, importName: string): string | null {
|
||||
const [namedImportNode] = getNamedImports(node, moduleName);
|
||||
if (!namedImportNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = `ImportSpecifier:has(Identifier[name="${importName}"]) > Identifier`;
|
||||
const identifiers = tsquery<Identifier>(namedImportNode, query);
|
||||
if (identifiers.length === 1) {
|
||||
return identifiers[0].text;
|
||||
}
|
||||
if (identifiers.length > 1) {
|
||||
return identifiers[identifiers.length - 1].text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findClasses(node: Node): ClassDeclaration[] {
|
||||
const query = 'ClassDeclaration';
|
||||
return tsquery<ClassDeclaration>(node, query);
|
||||
}
|
||||
|
||||
export function findClassPropertyByType(node: ClassDeclaration, type: string): string | null {
|
||||
return findClassPropertyConstructorParameterByType(node, type) || findClassPropertyDeclarationByType(node, type);
|
||||
}
|
||||
|
||||
export function findClassPropertyConstructorParameterByType(node: ClassDeclaration, type: string): string | null {
|
||||
const query = `Constructor Parameter:has(TypeReference > Identifier[name="${type}"]):has(PublicKeyword,ProtectedKeyword,PrivateKeyword) > Identifier`;
|
||||
const [result] = tsquery<Identifier>(node, query);
|
||||
if (result) {
|
||||
return result.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findClassPropertyDeclarationByType(node: ClassDeclaration, type: string): string | null {
|
||||
const query = `PropertyDeclaration:has(TypeReference > Identifier[name="${type}"]) > Identifier`;
|
||||
const [result] = tsquery<Identifier>(node, query);
|
||||
if (result) {
|
||||
return result.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findFunctionCallExpressions(node: Node, fnName: string | string[]): CallExpression[] {
|
||||
if (Array.isArray(fnName)) {
|
||||
fnName = fnName.join('|');
|
||||
}
|
||||
const query = `CallExpression:has(Identifier[name="${fnName}"]):not(:has(PropertyAccessExpression))`;
|
||||
const nodes = tsquery<CallExpression>(node, query);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function findMethodCallExpression(node: Node, prop: string, fnName: string | string[]): CallExpression[] {
|
||||
if (Array.isArray(fnName)) {
|
||||
fnName = fnName.join('|');
|
||||
}
|
||||
const query = `CallExpression > PropertyAccessExpression:has(Identifier[name=/^(${fnName})$/]):has(PropertyAccessExpression:has(Identifier[name="${prop}"]):has(ThisKeyword))`;
|
||||
let nodes = tsquery<PropertyAccessExpression>(node, query).map(node => node.parent as CallExpression);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function getStringsFromExpression(expression: Expression): string[] {
|
||||
if (isStringLiteralLike(expression)) {
|
||||
return [expression.text];
|
||||
}
|
||||
|
||||
if (isArrayLiteralExpression(expression)) {
|
||||
return expression.elements.reduce((result: string[], element: Expression) => {
|
||||
const strings = this.getStringsFromExpression(element);
|
||||
return [
|
||||
...result,
|
||||
...strings
|
||||
];
|
||||
}, []);
|
||||
}
|
||||
|
||||
if (isBinaryExpression(expression)) {
|
||||
const [left] = this.getStringsFromExpression(expression.left);
|
||||
const [right] = this.getStringsFromExpression(expression.right);
|
||||
|
||||
if (expression.operatorToken.kind === SyntaxKind.PlusToken) {
|
||||
if (typeof left === 'string' && typeof right === 'string') {
|
||||
return [left + right];
|
||||
}
|
||||
}
|
||||
|
||||
if (expression.operatorToken.kind === SyntaxKind.BarBarToken) {
|
||||
const result = [];
|
||||
if (typeof left === 'string') {
|
||||
result.push(left);
|
||||
}
|
||||
if (typeof right === 'string') {
|
||||
result.push(right);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (isConditionalExpression(expression)) {
|
||||
const [whenTrue] = this.getStringsFromExpression(expression.whenTrue);
|
||||
const [whenFalse] = this.getStringsFromExpression(expression.whenFalse);
|
||||
|
||||
const result = [];
|
||||
if (typeof whenTrue === 'string') {
|
||||
result.push(whenTrue);
|
||||
}
|
||||
if (typeof whenFalse === 'string') {
|
||||
result.push(whenFalse);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return [];
|
||||
}
|
Reference in New Issue
Block a user