From 6387c0e85aa6736474492a231c27be7c5a3e7ed7 Mon Sep 17 00:00:00 2001 From: Shahar Kazaz Date: Fri, 3 Oct 2025 15:24:18 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(transloco):=20=F0=9F=8E=B8=20auto-dete?= =?UTF-8?q?ct=20public=20folder=20in=20ng-add?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ Closes: #818 --- .../transloco-loader/transloco-loader.__ts__ | 2 +- .../ng-add/generators/http-loader.gen.ts | 8 +- libs/transloco/schematics/ng-add/index.ts | 35 ++++- .../schematics/ng-add/ng-add.spec.ts | 125 ++++++++++++++++++ libs/transloco/schematics/ng-add/schema.json | 2 +- 5 files changed, 168 insertions(+), 4 deletions(-) diff --git a/libs/transloco/schematics/ng-add/files/transloco-loader/transloco-loader.__ts__ b/libs/transloco/schematics/ng-add/files/transloco-loader/transloco-loader.__ts__ index 0badd1f0..649a7ac6 100644 --- a/libs/transloco/schematics/ng-add/files/transloco-loader/transloco-loader.__ts__ +++ b/libs/transloco/schematics/ng-add/files/transloco-loader/transloco-loader.__ts__ @@ -7,6 +7,6 @@ export class TranslocoHttpLoader implements TranslocoLoader { private http = inject(HttpClient); getTranslation(lang: string) { - return this.http.get(`<%= loaderPrefix %>/assets/i18n/${lang}.json`); + return this.http.get(`<%= loaderPrefix %>/<%= urlPath %>${lang}.json`); } } diff --git a/libs/transloco/schematics/ng-add/generators/http-loader.gen.ts b/libs/transloco/schematics/ng-add/generators/http-loader.gen.ts index 32e40f8f..15999fe8 100644 --- a/libs/transloco/schematics/ng-add/generators/http-loader.gen.ts +++ b/libs/transloco/schematics/ng-add/generators/http-loader.gen.ts @@ -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), ]); diff --git a/libs/transloco/schematics/ng-add/index.ts b/libs/transloco/schematics/ng-add/index.ts index 8ef46534..64ebba83 100644 --- a/libs/transloco/schematics/ng-add/index.ts +++ b/libs/transloco/schematics/ng-add/index.ts @@ -74,6 +74,35 @@ function resolveLoaderPath({ return resolved; } +function detectAssetsPath(host: Tree, sourceRoot: string): string { + // Primary: Check existing folder structure + if (host.exists('/public')) { + return 'public/i18n/'; + } + if (host.exists(`${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']; + const majorVersion = parseInt(version?.replace(/[^\d].*/, '') || '0'); + 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()); @@ -85,7 +114,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 @@ -124,6 +156,7 @@ export function ngAdd(options: SchemaOptions): Rule { createLoaderFile({ ssr: options.ssr, loaderPath, + urlPath, }), ), ); diff --git a/libs/transloco/schematics/ng-add/ng-add.spec.ts b/libs/transloco/schematics/ng-add/ng-add.spec.ts index 2ac3f7ed..49297d95 100644 --- a/libs/transloco/schematics/ng-add/ng-add.spec.ts +++ b/libs/transloco/schematics/ng-add/ng-add.spec.ts @@ -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) { diff --git a/libs/transloco/schematics/ng-add/schema.json b/libs/transloco/schematics/ng-add/schema.json index f9e80b8e..6c73bdea 100644 --- a/libs/transloco/schematics/ng-add/schema.json +++ b/libs/transloco/schematics/ng-add/schema.json @@ -33,7 +33,7 @@ }, "path": { "type": "string", - "default": "assets/i18n/", + "description": "Translation files path (auto-detects public/ for Angular 18+)", "alias": "p" }, "project": { From de8db55224f18139a2dd94915437cfe9070f3ad6 Mon Sep 17 00:00:00 2001 From: Shahar Kazaz Date: Sat, 11 Oct 2025 15:32:46 +0300 Subject: [PATCH 2/3] =?UTF-8?q?test(transloco):=20=F0=9F=92=8D=20Add=20sch?= =?UTF-8?q?ematics=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/transloco/jest.config.schematics.js | 31 ++++++++++++++++++++++++ libs/transloco/project.json | 12 +++++++++ 2 files changed, 43 insertions(+) create mode 100644 libs/transloco/jest.config.schematics.js diff --git a/libs/transloco/jest.config.schematics.js b/libs/transloco/jest.config.schematics.js new file mode 100644 index 00000000..0a9558e5 --- /dev/null +++ b/libs/transloco/jest.config.schematics.js @@ -0,0 +1,31 @@ +module.exports = { + displayName: 'transloco-schematics', + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/schematics/**/*.spec.ts'], + moduleNameMapper: { + '^@jsverse/transloco-utils$': '/../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'], +}; diff --git a/libs/transloco/project.json b/libs/transloco/project.json index aab9cf54..2f8e8e38 100644 --- a/libs/transloco/project.json +++ b/libs/transloco/project.json @@ -39,6 +39,10 @@ "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", @@ -46,6 +50,14 @@ "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}"] From 82ee48dfbb689a3eee32680049276491ffa6fee8 Mon Sep 17 00:00:00 2001 From: Shahar Kazaz Date: Sat, 11 Oct 2025 15:33:01 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(transloco):=20=F0=9F=90=9B=20fix=20dete?= =?UTF-8?q?ction=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/transloco/schematics/ng-add/index.ts | 47 +++++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/libs/transloco/schematics/ng-add/index.ts b/libs/transloco/schematics/ng-add/index.ts index 64ebba83..acc87ac7 100644 --- a/libs/transloco/schematics/ng-add/index.ts +++ b/libs/transloco/schematics/ng-add/index.ts @@ -74,12 +74,49 @@ 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 { - // Primary: Check existing folder structure - if (host.exists('/public')) { + if (dirExists(host, '/public')) { return 'public/i18n/'; } - if (host.exists(`${sourceRoot}/assets`)) { + + if (dirExists(host, `${sourceRoot}/assets`)) { return `${sourceRoot}/assets/i18n/`; } @@ -89,7 +126,9 @@ function detectAssetsPath(host: Tree, sourceRoot: string): string { const version = packageJson.dependencies?.['@angular/core'] || packageJson.devDependencies?.['@angular/core']; - const majorVersion = parseInt(version?.replace(/[^\d].*/, '') || '0'); + // 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