Skip to content

Commit 33b6df9

Browse files
authored
feat(transloco): ๐ŸŽธ auto-detect public folder in ng-add (#879)
* feat(transloco): ๐ŸŽธ auto-detect public folder in ng-add โœ… Closes: #818
1 parent d462998 commit 33b6df9

File tree

7 files changed

+250
-4
lines changed

7 files changed

+250
-4
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
module.exports = {
2+
displayName: 'transloco-schematics',
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
testMatch: ['<rootDir>/schematics/**/*.spec.ts'],
6+
moduleNameMapper: {
7+
'^@jsverse/transloco-utils$': '<rootDir>/../transloco-utils/src/index.ts',
8+
},
9+
transform: {
10+
'^.+\\.[tj]s$': [
11+
'ts-jest',
12+
{
13+
tsconfig: {
14+
module: 'commonjs',
15+
target: 'es2020',
16+
lib: ['es2020'],
17+
declaration: false,
18+
strict: false,
19+
esModuleInterop: true,
20+
skipLibCheck: true,
21+
experimentalDecorators: true,
22+
emitDecoratorMetadata: true,
23+
resolveJsonModule: true,
24+
},
25+
},
26+
],
27+
},
28+
moduleFileExtensions: ['ts', 'js', 'json'],
29+
coverageDirectory: '../../coverage/libs/transloco-schematics',
30+
collectCoverageFrom: ['schematics/**/*.ts', '!schematics/**/*.spec.ts'],
31+
};

โ€Žlibs/transloco/project.jsonโ€Ž

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,25 @@
3939
"outputs": ["{workspaceRoot}/dist/libs/transloco"]
4040
},
4141
"test": {
42+
"dependsOn": ["test-karma", "test-jest"],
43+
"executor": "nx:noop"
44+
},
45+
"test-karma": {
4246
"executor": "@angular-devkit/build-angular:karma",
4347
"options": {
4448
"main": "libs/transloco/src/test-setup.ts",
4549
"tsConfig": "libs/transloco/tsconfig.spec.json",
4650
"karmaConfig": "libs/transloco/karma.conf.js"
4751
}
4852
},
53+
"test-jest": {
54+
"executor": "@nx/jest:jest",
55+
"outputs": ["{options.outputFile}"],
56+
"options": {
57+
"jestConfig": "libs/transloco/jest.config.schematics.js",
58+
"passWithNoTests": true
59+
}
60+
},
4961
"lint": {
5062
"executor": "@nx/eslint:lint",
5163
"outputs": ["{options.outputFile}"]

โ€Žlibs/transloco/schematics/ng-add/files/transloco-loader/transloco-loader.__ts__โ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ export class TranslocoHttpLoader implements TranslocoLoader {
77
private http = inject(HttpClient);
88

99
getTranslation(lang: string) {
10-
return this.http.get<Translation>(`<%= loaderPrefix %>/assets/i18n/${lang}.json`);
10+
return this.http.get<Translation>(`<%= loaderPrefix %>/<%= urlPath %>${lang}.json`);
1111
}
1212
}

โ€Žlibs/transloco/schematics/ng-add/generators/http-loader.gen.tsโ€Ž

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@ import { apply, move, template, url } from '@angular-devkit/schematics';
33
export interface CreateLoaderFileParams {
44
ssr: boolean;
55
loaderPath: string;
6+
urlPath: string;
67
}
78

8-
export function createLoaderFile({ ssr, loaderPath }: CreateLoaderFileParams) {
9+
export function createLoaderFile({
10+
ssr,
11+
loaderPath,
12+
urlPath,
13+
}: CreateLoaderFileParams) {
914
return apply(url(`./files/transloco-loader`), [
1015
template({
1116
// Replace the __ts__ with ts
1217
ts: 'ts',
1318
loaderPrefix: ssr ? '${environment.baseUrl}' : '',
19+
urlPath: urlPath,
1420
}),
1521
move('/', loaderPath),
1622
]);

โ€Žlibs/transloco/schematics/ng-add/index.tsโ€Ž

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,74 @@ function resolveLoaderPath({
7474
return resolved;
7575
}
7676

77+
/**
78+
* Checks whether a directory "exists" in the schematic Tree.
79+
*
80+
* In the Angular DevKit schematic virtual file system, directories are *not real entities* โ€”
81+
* only files exist. A directory is considered to "exist" if it has at least one file or
82+
* subdirectory (that itself contains files) under its path.
83+
*
84+
* This function returns `true` if the given directory path contains any files or subdirectories,
85+
* and `false` otherwise (including for empty or nonexistent directories).
86+
*
87+
* @param tree - The schematic virtual file system (Tree)
88+
* @param dirPath - The path to check, e.g. '/src' or '/public'
89+
*/
90+
function dirExists(tree: Tree, dirPath: string): boolean {
91+
const dir = tree.getDir(dirPath);
92+
return dir.subfiles.length > 0 || dir.subdirs.length > 0;
93+
}
94+
95+
/**
96+
* Detects the appropriate path for translation assets based on Angular project structure.
97+
*
98+
* Angular 18+ introduced a new project structure using a top-level `public/` directory instead
99+
* of `src/assets/` for static assets. This function implements a three-tier detection strategy
100+
* to determine where translation JSON files should be placed:
101+
*
102+
* 1. **Directory existence check**: If `/public` exists, assume Angular 18+ structure
103+
* 2. **Legacy assets check**: If `${sourceRoot}/assets` exists, use traditional structure
104+
* 3. **Version fallback**: Parse package.json @angular/core version as final determination
105+
*
106+
* The detection accounts for the fact that in schematics, we're working with a virtual file
107+
* system where directories only "exist" if they contain files, and we need to make the right
108+
* choice for where users expect their translation files to be placed.
109+
*
110+
* @param host - The schematic virtual file system (Tree)
111+
* @param sourceRoot - The source root path (typically 'src' or 'projects/app-name/src')
112+
* @returns The detected assets path, e.g. 'public/i18n/' or 'src/assets/i18n/'
113+
*/
114+
function detectAssetsPath(host: Tree, sourceRoot: string): string {
115+
if (dirExists(host, '/public')) {
116+
return 'public/i18n/';
117+
}
118+
119+
if (dirExists(host, `${sourceRoot}/assets`)) {
120+
return `${sourceRoot}/assets/i18n/`;
121+
}
122+
123+
// Fallback: Check package.json for Angular version
124+
try {
125+
const packageJson = JSON.parse(host.read('/package.json')!.toString());
126+
const version =
127+
packageJson.dependencies?.['@angular/core'] ||
128+
packageJson.devDependencies?.['@angular/core'];
129+
// Extract major version number from versions like "^18.2.0", "~17.0.0", ">=16.0.0", etc.
130+
const majorVersionMatch = version?.match(/(\d+)\./);
131+
const majorVersion = parseInt(majorVersionMatch[1]);
132+
return majorVersion >= 18 ? 'public/i18n/' : `${sourceRoot}/assets/i18n/`;
133+
} catch {
134+
return `${sourceRoot}/assets/i18n/`; // Safe default
135+
}
136+
}
137+
138+
function getUrlPathFromAssetsPath(assetsPath: string): string {
139+
if (assetsPath.startsWith('public/')) {
140+
return assetsPath.replace('public/', '');
141+
}
142+
return assetsPath;
143+
}
144+
77145
export function ngAdd(options: SchemaOptions): Rule {
78146
return async (host: Tree, context: SchematicContext) => {
79147
const langs = options.langs.split(',').map((l) => l.trim());
@@ -85,7 +153,10 @@ export function ngAdd(options: SchemaOptions): Rule {
85153
const project = getProject(host, options.project);
86154
const sourceRoot = project.sourceRoot ?? 'src';
87155
const isLib = project.projectType === 'library';
88-
const assetsPath = `${sourceRoot}/${options.path}`;
156+
const assetsPath = options.path
157+
? `${sourceRoot}/${options.path}`
158+
: detectAssetsPath(host, sourceRoot);
159+
const urlPath = getUrlPathFromAssetsPath(assetsPath);
89160
const mainPath = await getMainFilePath(host, options.project);
90161
const isStandalone = isStandaloneApp(host, mainPath);
91162
const modulePath = isStandalone
@@ -124,6 +195,7 @@ export function ngAdd(options: SchemaOptions): Rule {
124195
createLoaderFile({
125196
ssr: options.ssr,
126197
loaderPath,
198+
urlPath,
127199
}),
128200
),
129201
);

โ€Žlibs/transloco/schematics/ng-add/ng-add.spec.tsโ€Ž

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,131 @@ describe('ng add', () => {
5252
);
5353
});
5454
});
55+
56+
describe('Folder Detection', () => {
57+
it('should use public/i18n for Angular 18+ projects with public folder', async () => {
58+
const options: SchemaOptions = { project: 'bar' } as SchemaOptions;
59+
const initialTree = await createWorkspace(schematicRunner);
60+
61+
// Create public folder to simulate Angular 18+
62+
initialTree.create('/public/favicon.ico', '');
63+
64+
const tree = await schematicRunner.runSchematic(
65+
'ng-add',
66+
options,
67+
initialTree,
68+
);
69+
70+
expect(tree.files).toContain('/public/i18n/en.json');
71+
expect(tree.files).toContain('/public/i18n/es.json');
72+
73+
// Check that loader uses correct URL path
74+
const loaderContent = readFile(tree, 'app/transloco-loader.ts');
75+
expect(loaderContent).toContain('/i18n/${lang}.json');
76+
expect(loaderContent).not.toContain('/assets/i18n/');
77+
});
78+
79+
it('should use src/assets/i18n for projects with assets folder', async () => {
80+
const options: SchemaOptions = { project: 'bar' } as SchemaOptions;
81+
const initialTree = await createWorkspace(schematicRunner);
82+
83+
// Create assets folder to simulate traditional Angular structure
84+
initialTree.create('/projects/bar/src/assets/icons/icon.png', '');
85+
86+
const tree = await schematicRunner.runSchematic(
87+
'ng-add',
88+
options,
89+
initialTree,
90+
);
91+
92+
expect(tree.files).toContain('/projects/bar/src/assets/i18n/en.json');
93+
expect(tree.files).toContain('/projects/bar/src/assets/i18n/es.json');
94+
95+
// Check that loader uses correct URL path
96+
const loaderContent = readFile(tree, 'app/transloco-loader.ts');
97+
expect(loaderContent).toContain('/assets/i18n/${lang}.json');
98+
});
99+
100+
it('should respect custom path when specified', async () => {
101+
const options: SchemaOptions = {
102+
project: 'bar',
103+
path: 'custom/translations/',
104+
} as SchemaOptions;
105+
106+
const tree = await schematicRunner.runSchematic(
107+
'ng-add',
108+
options,
109+
await createWorkspace(schematicRunner),
110+
);
111+
112+
expect(tree.files).toContain(
113+
'/projects/bar/src/custom/translations/en.json',
114+
);
115+
expect(tree.files).toContain(
116+
'/projects/bar/src/custom/translations/es.json',
117+
);
118+
119+
// Check that loader uses correct URL path
120+
const loaderContent = readFile(tree, 'app/transloco-loader.ts');
121+
expect(loaderContent).toContain('/custom/translations/${lang}.json');
122+
});
123+
124+
it('should fallback to package.json version detection when folders are ambiguous', async () => {
125+
const options: SchemaOptions = { project: 'bar' } as SchemaOptions;
126+
const initialTree = await createWorkspace(schematicRunner);
127+
128+
// Simulate Angular 18+ by updating package.json
129+
const packageJson = JSON.parse(
130+
initialTree.read('/package.json')!.toString(),
131+
);
132+
packageJson.dependencies['@angular/core'] = '^18.0.0';
133+
initialTree.overwrite(
134+
'/package.json',
135+
JSON.stringify(packageJson, null, 2),
136+
);
137+
138+
const tree = await schematicRunner.runSchematic(
139+
'ng-add',
140+
options,
141+
initialTree,
142+
);
143+
144+
expect(tree.files).toContain('/public/i18n/en.json');
145+
expect(tree.files).toContain('/public/i18n/es.json');
146+
147+
// Check that loader uses correct URL path for Angular 18+
148+
const loaderContent = readFile(tree, 'app/transloco-loader.ts');
149+
expect(loaderContent).toContain('/i18n/${lang}.json');
150+
});
151+
152+
it('should fallback to assets for Angular <18 when package.json indicates older version', async () => {
153+
const options: SchemaOptions = { project: 'bar' } as SchemaOptions;
154+
const initialTree = await createWorkspace(schematicRunner);
155+
156+
// Simulate Angular 17 by updating package.json
157+
const packageJson = JSON.parse(
158+
initialTree.read('/package.json')!.toString(),
159+
);
160+
packageJson.dependencies['@angular/core'] = '^17.0.0';
161+
initialTree.overwrite(
162+
'/package.json',
163+
JSON.stringify(packageJson, null, 2),
164+
);
165+
166+
const tree = await schematicRunner.runSchematic(
167+
'ng-add',
168+
options,
169+
initialTree,
170+
);
171+
172+
expect(tree.files).toContain('/projects/bar/src/assets/i18n/en.json');
173+
expect(tree.files).toContain('/projects/bar/src/assets/i18n/es.json');
174+
175+
// Check that loader uses correct URL path for Angular <18
176+
const loaderContent = readFile(tree, 'app/transloco-loader.ts');
177+
expect(loaderContent).toContain('/assets/i18n/${lang}.json');
178+
});
179+
});
55180
});
56181

57182
function readFile(host: UnitTestTree, path: string) {

โ€Žlibs/transloco/schematics/ng-add/schema.jsonโ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
},
3434
"path": {
3535
"type": "string",
36-
"default": "assets/i18n/",
36+
"description": "Translation files path (auto-detects public/ for Angular 18+)",
3737
"alias": "p"
3838
},
3939
"project": {

0 commit comments

Comments
ย (0)