Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions libs/transloco-keys-manager/src/lib/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ export const optionDefinitions = [
type: String,
description: 'Where are the main translation files',
},
{
name: 'scope-provider-functions',
type: String,
multiple: true,
description:
'Additional function names that provide translation scopes (custom wrappers around provideTranslocoScope)',
},
{
name: 'service-names',
type: String,
multiple: true,
description:
'Additional service class names that wrap TranslocoService (e.g. TranslationsService)',
},
{ name: 'help', alias: 'h', type: Boolean, description: 'Help me, please!' },
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,22 @@ export function extractTSKeys(config: Config): ExtractionResult {
const translocoImport = /@(jsverse|ngneat)\/transloco/;
const translocoKeysManagerImport = /@(jsverse|ngneat)\/transloco-keys-manager/;
function TSExtractor(config: ExtractorConfig): ScopeMap {
const { file, scopes, defaultValue, scopeToKeys } = config;
const { file, scopes, defaultValue, scopeToKeys, serviceNames } = config;
const content = readFile(file);
const extractors = [];

const hasTranslocoImport = translocoImport.test(content);
const hasMarkerImport = translocoKeysManagerImport.test(content);
const hasTranslocoUsage = content.includes('transloco');
const hasCustomService =
serviceNames?.some((name) => content.includes(name)) ?? false;
const hasTranslocoUsage = content.includes('transloco') || hasCustomService;

if (hasTranslocoImport) {
extractors.push(serviceExtractor, pureFunctionExtractor, signalExtractor);
} else if (hasCustomService) {
// Custom wrapper services live behind arbitrary import paths, so the
// transloco import gate doesn't apply to them.
extractors.push(serviceExtractor);
}

if (hasMarkerImport) {
Expand Down Expand Up @@ -65,7 +71,7 @@ function TSExtractor(config: ExtractorConfig): ScopeMap {
const ast = tsquery.ast(content, undefined, ScriptKind.TS);

extractors
.map((ex) => ex(ast))
.map((ex) => ex(ast, serviceNames))
.flat()
.forEach(({ key, lang, params }) => {
const [keyWithoutScope, scopeAlias] = resolveAliasAndKeyFromService(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,27 @@ import ts, { SourceFile } from 'typescript';
import { buildKeysFromASTNodes } from './build-keys-from-ast-nodes';
import { TSExtractorResult } from './types';

function buildInjectFunctionQuery(nodeType: string) {
return `${nodeType}:has(CallExpression:has(Identifier[name=inject]):has(Identifier[name=TranslocoService]))`;
function buildInjectFunctionQuery(nodeType: string, serviceName: string) {
return `${nodeType}:has(CallExpression:has(Identifier[name=inject]):has(Identifier[name=${serviceName}]))`;
}

export function serviceExtractor(ast: SourceFile): TSExtractorResult {
const constructorInjection =
'Constructor Parameter:has(TypeReference Identifier[name=TranslocoService])';
const injectFunction = ['PropertyDeclaration', 'VariableDeclaration'].map(
buildInjectFunctionQuery,
export function serviceExtractor(
ast: SourceFile,
serviceNames: string[] = [],
): TSExtractorResult {
const allServiceNames = ['TranslocoService', ...serviceNames];
const constructorInjections = allServiceNames.map(
(name) =>
`Constructor Parameter:has(TypeReference Identifier[name=${name}])`,
);
const injectFunctions = allServiceNames.flatMap((name) =>
['PropertyDeclaration', 'VariableDeclaration'].map((nodeType) =>
buildInjectFunctionQuery(nodeType, name),
),
);
const serviceNameQuery = [...constructorInjections, ...injectFunctions].join(
',',
);
const serviceNameQuery = [constructorInjection, ...injectFunction].join(',');
const serviceNameNodes = tsquery(ast, serviceNameQuery);

let result: TSExtractorResult = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { devlog } from '../../utils/logger';
import { normalizedGlob } from '../../utils/normalize-glob-path';

export function extractKeys(
{ input, scopes, defaultValue, files }: Config,
{ input, scopes, defaultValue, files, serviceNames }: Config,
fileType: FileType,
extractor: (config: ExtractorConfig) => ScopeMap,
): ExtractionResult {
Expand All @@ -22,7 +22,13 @@ export function extractKeys(

for (const file of fileList) {
devlog('extraction', 'Extracting keys', { file, fileType });
scopeToKeys = extractor({ file, defaultValue, scopes, scopeToKeys });
scopeToKeys = extractor({
file,
defaultValue,
scopes,
scopeToKeys,
serviceNames,
});
}

return { scopeToKeys, fileCount: fileList.length };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type TranslationTestCase =
| 'config-options/unflat-problematic-keys'
| 'config-options/multi-input'
| 'config-options/scope-mapping'
| 'config-options/custom-providers'
| 'config-options/remove-extra-keys'
| 'comments';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { testUnflatExtraction } from './config-options/unflat/unflat-spec';
import { testScopeMappingConfig } from './config-options/scope-mapping/scope-mapping-spec';
import { testRemoveExtraKeysConfig } from './config-options/remove-extra-keys/remove-extra-keys-spec';
import { testMultiInputsConfig } from './config-options/multi-input/multi-input-spec';
import { testCustomProvidersConfig } from './config-options/custom-providers/custom-providers-spec';

const formats: FileFormats[] = ['pot', 'json'];

Expand Down Expand Up @@ -75,6 +76,8 @@ describe.each(formats)('buildTranslationFiles in %s', (fileFormat) => {
testMultiInputsConfig(fileFormat);

testRemoveExtraKeysConfig(fileFormat);

testCustomProvidersConfig(fileFormat);
});

testCommentsExtraction(fileFormat);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { beforeEach, describe, it } from 'vitest';

import {
assertTranslation,
buildConfig,
removeI18nFolder,
sourceRoot,
TranslationTestCase,
} from '../../build-translation-utils';
import { defaultValue, mockResolveProjectBasePath } from '../../../spec-utils';
import { Config } from '../../../../types';

mockResolveProjectBasePath(sourceRoot);

/**
* With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards.
* This thing is still in WIP at Jest, so keep an eye on it.
* @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm
*/
const { buildTranslationFiles } = await import('../../../../keys-builder');

export function testCustomProvidersConfig(fileFormat: Config['fileFormat']) {
describe('Custom scope providers and services', () => {
const type: TranslationTestCase = 'config-options/custom-providers';
const config = buildConfig({
type,
config: {
fileFormat,
scopeProviderFunctions: ['provideScopedTranslations'],
serviceNames: ['TranslationsService'],
},
});

beforeEach(() => removeI18nFolder(type));

it('should extract keys from custom services without a transloco import', () => {
const expected = {
'custom-service.inject': defaultValue,
'custom-service.constructor': defaultValue,
};

buildTranslationFiles(config);
assertTranslation({ type, expected, fileFormat });
});

it('should resolve scopes provided by custom scope provider functions', () => {
buildTranslationFiles(config);
assertTranslation({
type,
expected: { '1': defaultValue },
path: 'custom-page/',
fileFormat,
});
assertTranslation({
type,
expected: { '2': defaultValue },
path: 'other-page/',
fileFormat,
});
});
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { provideScopedTranslations } from '@app/shared/translations';

@Component({
selector: 'app-custom-scopes',
templateUrl: './custom-scopes.component.html',
providers: [
provideScopedTranslations('custom-page'),
provideScopedTranslations({ scope: 'other-page', alias: 'other' }),
],
})
export class CustomScopesComponent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { TranslationsService } from '@app/shared/translations';

export class TodosFacade {
constructor(private translations: TranslationsService) {}

notify() {
this.translations.translate('custom-service.constructor');
this.translations.translate('2', {}, 'other-page');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Component, OnInit, inject } from '@angular/core';
import { TranslationsService } from '@app/shared/translations';

@Component({
selector: 'custom-inject',
template: ``,
})
export class CustomInjectComponent implements OnInit {
private translations = inject(TranslationsService);

ngOnInit() {
this.translations.translate('custom-service.inject');
this.translations.translate('1', {}, 'custom-page');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const sharedOptions = [
'unflat',
'defaultValue',
'translationsPath',
'scopeProviderFunctions',
'serviceNames',
'help',
];

Expand Down
3 changes: 3 additions & 0 deletions libs/transloco-keys-manager/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export type Config = {
unflat: boolean;
command: 'extract' | 'find';
fileFormat: FileFormats;
scopeProviderFunctions?: string[];
serviceNames?: string[];
/** @internal - Used for ${sourceRoot} interpolation in scopePathMap */
__sourceRoot?: string;
};
Expand All @@ -37,6 +39,7 @@ export type ExtractorConfig = {
scopes: Scopes;
defaultValue?: string;
scopeToKeys: ScopeMap;
serviceNames?: string[];
};

export type Scopes = {
Expand Down
5 changes: 4 additions & 1 deletion libs/transloco-keys-manager/src/lib/utils/resolve-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ export function resolveConfig(inlineConfig: Partial<Config>): Config {

validateDirectories(mergedConfig);

updateScopesMap({ input: mergedConfig.input });
updateScopesMap({
input: mergedConfig.input,
scopeProviderFunctions: mergedConfig.scopeProviderFunctions,
});

devlog('scopes', 'Scopes', {
'Scopes map': getScopes().scopeToAlias,
Expand Down
30 changes: 26 additions & 4 deletions libs/transloco-keys-manager/src/lib/utils/update-scopes-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,21 @@ interface ScopeDef {
type ScopeResolver = (node: Node) => ScopeDef[];

const tokenProviderQuery = `ObjectLiteralExpression:has(PropertyAssignment > Identifier[name=TRANSLOCO_SCOPE]) > PropertyAssignment > Identifier[name=/useValue|useFactory/]`;
const functionProviderQuery = `CallExpression > Identifier[name=provideTranslocoScope]`;

function buildFunctionProviderQuery(scopeProviderFunctions: string[] = []) {
return ['provideTranslocoScope', ...scopeProviderFunctions]
.map((name) => `CallExpression > Identifier[name=${name}]`)
.join(', ');
}

function buildProviderRegex(scopeProviderFunctions: string[] = []) {
const names = [
'TRANSLOCO_SCOPE',
'provideTranslocoScope',
...scopeProviderFunctions,
];
return new RegExp(`(${names.join('|')})`);
}

function stringQueryDef(rootNode: Node) {
return (
Expand Down Expand Up @@ -62,9 +76,11 @@ function objectQueryDef(rootNode: Node) {
// Order is important, we check if it's an object first, then string
const scopeValueQueries: ScopeResolver[] = [objectQueryDef, stringQueryDef];

type Options = { input?: string[]; files?: string[] };

const translocoProvider = /(TRANSLOCO_SCOPE|provideTranslocoScope)/;
type Options = {
input?: string[];
files?: string[];
scopeProviderFunctions?: string[];
};

export function updateScopesMap(
options: Omit<Options, 'input'>,
Expand All @@ -75,12 +91,18 @@ export function updateScopesMap(
export function updateScopesMap({
input,
files,
scopeProviderFunctions,
}: Options): Scopes['aliasToScope'] {
const tsFiles =
files || input!.map((path) => normalizedGlob(`${path}/**/*.ts`)).flat();
// Return only the new scopes (for the plugin)
const aliasToScope: Record<Alias, Scope> = {};

const translocoProvider = buildProviderRegex(scopeProviderFunctions);
const functionProviderQuery = buildFunctionProviderQuery(
scopeProviderFunctions,
);

for (const file of tsFiles) {
const content = readFile(file);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ export class TranslocoExtractKeysWebpackPlugin {
let tsResult = initExtraction();
if (keysExtractions.ts.length) {
// Maybe someone added a TRANSLOCO_SCOPE
const newScopes = updateScopesMap({ files: keysExtractions.ts });
const newScopes = updateScopesMap({
files: keysExtractions.ts,
scopeProviderFunctions: this.config.scopeProviderFunctions,
});

const paths = buildScopeFilePaths({
aliasToScope: newScopes,
Expand Down
2 changes: 2 additions & 0 deletions libs/transloco-utils/src/lib/transloco-utils.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@ export interface TranslocoGlobalConfig {
defaultValue?: string | undefined;
unflat?: boolean;
sort?: boolean;
scopeProviderFunctions?: string[];
serviceNames?: string[];
};
}