Skip to content
Merged
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
31 changes: 31 additions & 0 deletions libs/transloco/jest.config.schematics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module.exports = {
displayName: 'transloco-schematics',
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['<rootDir>/schematics/**/*.spec.ts'],
moduleNameMapper: {
'^@jsverse/transloco-utils$': '<rootDir>/../transloco-utils/src/index.ts',
},
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{
tsconfig: {
module: 'commonjs',
target: 'es2020',
lib: ['es2020'],
declaration: false,
strict: false,
esModuleInterop: true,
skipLibCheck: true,
experimentalDecorators: true,
emitDecoratorMetadata: true,
resolveJsonModule: true,
},
},
],
},
moduleFileExtensions: ['ts', 'js', 'json'],
coverageDirectory: '../../coverage/libs/transloco-schematics',
collectCoverageFrom: ['schematics/**/*.ts', '!schematics/**/*.spec.ts'],
};
12 changes: 12 additions & 0 deletions libs/transloco/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,25 @@
"outputs": ["{workspaceRoot}/dist/libs/transloco"]
},
"test": {
"dependsOn": ["test-karma", "test-jest"],
"executor": "nx:noop"
},
"test-karma": {
"executor": "@angular-devkit/build-angular:karma",
"options": {
"main": "libs/transloco/src/test-setup.ts",
"tsConfig": "libs/transloco/tsconfig.spec.json",
"karmaConfig": "libs/transloco/karma.conf.js"
}
},
"test-jest": {
"executor": "@nx/jest:jest",
"outputs": ["{options.outputFile}"],
"options": {
"jestConfig": "libs/transloco/jest.config.schematics.js",
"passWithNoTests": true
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ export class TranslocoHttpLoader implements TranslocoLoader {
private http = inject(HttpClient);

getTranslation(lang: string) {
return this.http.get<Translation>(`<%= loaderPrefix %>/assets/i18n/${lang}.json`);
return this.http.get<Translation>(`<%= loaderPrefix %>/<%= urlPath %>${lang}.json`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import { apply, move, template, url } from '@angular-devkit/schematics';
export interface CreateLoaderFileParams {
ssr: boolean;
loaderPath: string;
urlPath: string;
}

export function createLoaderFile({ ssr, loaderPath }: CreateLoaderFileParams) {
export function createLoaderFile({
ssr,
loaderPath,
urlPath,
}: CreateLoaderFileParams) {
return apply(url(`./files/transloco-loader`), [
template({
// Replace the __ts__ with ts
ts: 'ts',
loaderPrefix: ssr ? '${environment.baseUrl}' : '',
urlPath: urlPath,
}),
move('/', loaderPath),
]);
Expand Down
74 changes: 73 additions & 1 deletion libs/transloco/schematics/ng-add/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,74 @@ function resolveLoaderPath({
return resolved;
}

/**
* Checks whether a directory "exists" in the schematic Tree.
*
* In the Angular DevKit schematic virtual file system, directories are *not real entities* —
* only files exist. A directory is considered to "exist" if it has at least one file or
* subdirectory (that itself contains files) under its path.
*
* This function returns `true` if the given directory path contains any files or subdirectories,
* and `false` otherwise (including for empty or nonexistent directories).
*
* @param tree - The schematic virtual file system (Tree)
* @param dirPath - The path to check, e.g. '/src' or '/public'
*/
function dirExists(tree: Tree, dirPath: string): boolean {
const dir = tree.getDir(dirPath);
return dir.subfiles.length > 0 || dir.subdirs.length > 0;
}

/**
* Detects the appropriate path for translation assets based on Angular project structure.
*
* Angular 18+ introduced a new project structure using a top-level `public/` directory instead
* of `src/assets/` for static assets. This function implements a three-tier detection strategy
* to determine where translation JSON files should be placed:
*
* 1. **Directory existence check**: If `/public` exists, assume Angular 18+ structure
* 2. **Legacy assets check**: If `${sourceRoot}/assets` exists, use traditional structure
* 3. **Version fallback**: Parse package.json @angular/core version as final determination
*
* The detection accounts for the fact that in schematics, we're working with a virtual file
* system where directories only "exist" if they contain files, and we need to make the right
* choice for where users expect their translation files to be placed.
*
* @param host - The schematic virtual file system (Tree)
* @param sourceRoot - The source root path (typically 'src' or 'projects/app-name/src')
* @returns The detected assets path, e.g. 'public/i18n/' or 'src/assets/i18n/'
*/
function detectAssetsPath(host: Tree, sourceRoot: string): string {
if (dirExists(host, '/public')) {
return 'public/i18n/';
}

if (dirExists(host, `${sourceRoot}/assets`)) {
return `${sourceRoot}/assets/i18n/`;
}

// Fallback: Check package.json for Angular version
try {
const packageJson = JSON.parse(host.read('/package.json')!.toString());
const version =
packageJson.dependencies?.['@angular/core'] ||
packageJson.devDependencies?.['@angular/core'];
// Extract major version number from versions like "^18.2.0", "~17.0.0", ">=16.0.0", etc.
const majorVersionMatch = version?.match(/(\d+)\./);
const majorVersion = parseInt(majorVersionMatch[1]);
return majorVersion >= 18 ? 'public/i18n/' : `${sourceRoot}/assets/i18n/`;
} catch {
return `${sourceRoot}/assets/i18n/`; // Safe default
}
}

function getUrlPathFromAssetsPath(assetsPath: string): string {
if (assetsPath.startsWith('public/')) {
return assetsPath.replace('public/', '');
}
return assetsPath;
}

export function ngAdd(options: SchemaOptions): Rule {
return async (host: Tree, context: SchematicContext) => {
const langs = options.langs.split(',').map((l) => l.trim());
Expand All @@ -85,7 +153,10 @@ export function ngAdd(options: SchemaOptions): Rule {
const project = getProject(host, options.project);
const sourceRoot = project.sourceRoot ?? 'src';
const isLib = project.projectType === 'library';
const assetsPath = `${sourceRoot}/${options.path}`;
const assetsPath = options.path
? `${sourceRoot}/${options.path}`
: detectAssetsPath(host, sourceRoot);
const urlPath = getUrlPathFromAssetsPath(assetsPath);
const mainPath = await getMainFilePath(host, options.project);
const isStandalone = isStandaloneApp(host, mainPath);
const modulePath = isStandalone
Expand Down Expand Up @@ -124,6 +195,7 @@ export function ngAdd(options: SchemaOptions): Rule {
createLoaderFile({
ssr: options.ssr,
loaderPath,
urlPath,
}),
),
);
Expand Down
125 changes: 125 additions & 0 deletions libs/transloco/schematics/ng-add/ng-add.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,131 @@ describe('ng add', () => {
);
});
});

describe('Folder Detection', () => {
it('should use public/i18n for Angular 18+ projects with public folder', async () => {
const options: SchemaOptions = { project: 'bar' } as SchemaOptions;
const initialTree = await createWorkspace(schematicRunner);

// Create public folder to simulate Angular 18+
initialTree.create('/public/favicon.ico', '');

const tree = await schematicRunner.runSchematic(
'ng-add',
options,
initialTree,
);

expect(tree.files).toContain('/public/i18n/en.json');
expect(tree.files).toContain('/public/i18n/es.json');

// Check that loader uses correct URL path
const loaderContent = readFile(tree, 'app/transloco-loader.ts');
expect(loaderContent).toContain('/i18n/${lang}.json');
expect(loaderContent).not.toContain('/assets/i18n/');
});

it('should use src/assets/i18n for projects with assets folder', async () => {
const options: SchemaOptions = { project: 'bar' } as SchemaOptions;
const initialTree = await createWorkspace(schematicRunner);

// Create assets folder to simulate traditional Angular structure
initialTree.create('/projects/bar/src/assets/icons/icon.png', '');

const tree = await schematicRunner.runSchematic(
'ng-add',
options,
initialTree,
);

expect(tree.files).toContain('/projects/bar/src/assets/i18n/en.json');
expect(tree.files).toContain('/projects/bar/src/assets/i18n/es.json');

// Check that loader uses correct URL path
const loaderContent = readFile(tree, 'app/transloco-loader.ts');
expect(loaderContent).toContain('/assets/i18n/${lang}.json');
});

it('should respect custom path when specified', async () => {
const options: SchemaOptions = {
project: 'bar',
path: 'custom/translations/',
} as SchemaOptions;

const tree = await schematicRunner.runSchematic(
'ng-add',
options,
await createWorkspace(schematicRunner),
);

expect(tree.files).toContain(
'/projects/bar/src/custom/translations/en.json',
);
expect(tree.files).toContain(
'/projects/bar/src/custom/translations/es.json',
);

// Check that loader uses correct URL path
const loaderContent = readFile(tree, 'app/transloco-loader.ts');
expect(loaderContent).toContain('/custom/translations/${lang}.json');
});

it('should fallback to package.json version detection when folders are ambiguous', async () => {
const options: SchemaOptions = { project: 'bar' } as SchemaOptions;
const initialTree = await createWorkspace(schematicRunner);

// Simulate Angular 18+ by updating package.json
const packageJson = JSON.parse(
initialTree.read('/package.json')!.toString(),
);
packageJson.dependencies['@angular/core'] = '^18.0.0';
initialTree.overwrite(
'/package.json',
JSON.stringify(packageJson, null, 2),
);

const tree = await schematicRunner.runSchematic(
'ng-add',
options,
initialTree,
);

expect(tree.files).toContain('/public/i18n/en.json');
expect(tree.files).toContain('/public/i18n/es.json');

// Check that loader uses correct URL path for Angular 18+
const loaderContent = readFile(tree, 'app/transloco-loader.ts');
expect(loaderContent).toContain('/i18n/${lang}.json');
});

it('should fallback to assets for Angular <18 when package.json indicates older version', async () => {
const options: SchemaOptions = { project: 'bar' } as SchemaOptions;
const initialTree = await createWorkspace(schematicRunner);

// Simulate Angular 17 by updating package.json
const packageJson = JSON.parse(
initialTree.read('/package.json')!.toString(),
);
packageJson.dependencies['@angular/core'] = '^17.0.0';
initialTree.overwrite(
'/package.json',
JSON.stringify(packageJson, null, 2),
);

const tree = await schematicRunner.runSchematic(
'ng-add',
options,
initialTree,
);

expect(tree.files).toContain('/projects/bar/src/assets/i18n/en.json');
expect(tree.files).toContain('/projects/bar/src/assets/i18n/es.json');

// Check that loader uses correct URL path for Angular <18
const loaderContent = readFile(tree, 'app/transloco-loader.ts');
expect(loaderContent).toContain('/assets/i18n/${lang}.json');
});
});
});

function readFile(host: UnitTestTree, path: string) {
Expand Down
2 changes: 1 addition & 1 deletion libs/transloco/schematics/ng-add/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
},
"path": {
"type": "string",
"default": "assets/i18n/",
"description": "Translation files path (auto-detects public/ for Angular 18+)",
"alias": "p"
},
"project": {
Expand Down