Skip to content

Commit c60f221

Browse files
arbrandesclaude
andcommitted
feat: add dynamic @src alias resolution for consuming apps
Replace static @src webpack alias with ClosestSrcResolverPlugin that dynamically resolves @src to the nearest src directory from the importing file. Each consuming app now defines its own @src path in tsconfig.json for TypeScript/IDE support. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2badcaf commit c60f221

8 files changed

Lines changed: 99 additions & 12 deletions

File tree

docs/how_tos/migrate-frontend-app.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,12 @@ Create a `tsconfig.json` file and add the following contents to it:
259259
{
260260
"extends": "@openedx/frontend-base/config/tsconfig.json",
261261
"compilerOptions": {
262+
"baseUrl": ".",
262263
"rootDir": ".",
263264
"outDir": "dist",
265+
"paths": {
266+
"@src/*": ["./src/*"]
267+
}
264268
},
265269
"include": [
266270
"src/**/*",
@@ -275,6 +279,26 @@ Create a `tsconfig.json` file and add the following contents to it:
275279

276280
This assumes you have a `src` folder and your build goes in `dist`, which is the best practice.
277281

282+
The `@src` path alias
283+
---------------------
284+
285+
The `paths` configuration above sets up the `@src` alias, which allows you to import from your app's `src` directory using `@src/...` instead of relative paths. For example:
286+
287+
```typescript
288+
// Instead of:
289+
import { MyComponent } from '../../../components/MyComponent';
290+
291+
// You can use:
292+
import { MyComponent } from '@src/components/MyComponent';
293+
```
294+
295+
Each consuming app must define its own `@src` path mapping in its `tsconfig.json`. This is because:
296+
297+
1. **TypeScript** uses the static path mapping in your `tsconfig.json` for IDE support (autocomplete, go-to-definition, type checking)
298+
2. **Webpack** uses a resolver plugin that dynamically finds the closest `src` directory relative to the importing file at build time
299+
300+
This approach ensures that `@src` always resolves to your app's own `src` directory, even in complex project structures.
301+
278302

279303
Edit jest.config.js
280304
===================

test-site/tsconfig.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
{
22
"extends": "@openedx/frontend-base/config/tsconfig.json",
33
"compilerOptions": {
4+
"baseUrl": ".",
45
"rootDir": ".",
5-
"outDir": "dist"
6+
"outDir": "dist",
7+
"paths": {
8+
"@src/*": ["./src/*"]
9+
}
610
},
711
"include": [
812
"eslint.config.js",

tools/tsconfig.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@
55
"outDir": "dist",
66
"noEmit": false,
77
"allowJs": true,
8-
"resolveJsonModule": true,
9-
"paths": {
10-
"@src/*": ["./src/*"]
11-
}
8+
"resolveJsonModule": true
129
},
1310
"include": [
1411
"babel/**/*",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { Resolver } from 'webpack';
4+
5+
/**
6+
* A webpack resolver plugin that resolves `@src` imports to the closest
7+
* `src` directory by walking up from the importing file's location.
8+
*
9+
* This allows apps to have their own `src` directories, with `@src` always
10+
* resolving to the nearest one relative to the file doing the import.
11+
*/
12+
class ClosestSrcResolverPlugin {
13+
apply(resolver: Resolver) {
14+
const target = resolver.ensureHook('resolve');
15+
16+
resolver.getHook('resolve').tapAsync(
17+
'ClosestSrcResolverPlugin',
18+
(request: any, resolveContext: any, callback: (err?: null | Error, result?: any) => void) => {
19+
if (!request.request?.startsWith('@src')) {
20+
return callback();
21+
}
22+
23+
// Get the directory of the file doing the import
24+
const issuer = request.context?.issuer;
25+
if (!issuer) {
26+
return callback();
27+
}
28+
29+
// Walk up from the issuer to find closest 'src' directory,
30+
// but don't go above the current working directory
31+
const cwd = process.cwd();
32+
let dir = path.dirname(issuer);
33+
let srcPath: string | null = null;
34+
35+
while (dir.startsWith(cwd) && dir !== path.parse(dir).root) {
36+
const candidate = path.join(dir, 'src');
37+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
38+
srcPath = candidate;
39+
break;
40+
}
41+
dir = path.dirname(dir);
42+
}
43+
44+
if (!srcPath) {
45+
return callback();
46+
}
47+
48+
// Replace @src with the actual path
49+
const newRequest = request.request.replace(/^@src/, srcPath);
50+
51+
const obj = {
52+
...request,
53+
request: newRequest,
54+
};
55+
56+
resolver.doResolve(target, obj, null, resolveContext, callback);
57+
}
58+
);
59+
}
60+
}
61+
62+
export default ClosestSrcResolverPlugin;

tools/webpack/webpack.config.build.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getStylesheetRule
1414
} from './common-config';
1515

16+
import ClosestSrcResolverPlugin from './plugins/ClosestSrcResolverPlugin';
1617
import getLocalAliases from './utils/getLocalAliases';
1718
import getPublicPath from './utils/getPublicPath';
1819
import getResolvedSiteConfigPath from './utils/getResolvedSiteConfigPath';
@@ -36,9 +37,9 @@ const config: Configuration = {
3637
alias: {
3738
...aliases,
3839
'site.config': resolvedSiteConfigPath,
39-
'@src': path.resolve(process.cwd(), 'src'),
4040
},
4141
extensions: ['.js', '.jsx', '.ts', '.tsx'],
42+
plugins: [new ClosestSrcResolverPlugin()],
4243
},
4344
module: {
4445
rules: [

tools/webpack/webpack.config.dev.shell.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from './common-config';
1616

1717
import HtmlWebpackPlugin from 'html-webpack-plugin';
18+
import ClosestSrcResolverPlugin from './plugins/ClosestSrcResolverPlugin';
1819
import getLocalAliases from './utils/getLocalAliases';
1920
import getPublicPath from './utils/getPublicPath';
2021
import getResolvedSiteConfigPath from './utils/getResolvedSiteConfigPath';
@@ -34,9 +35,9 @@ const config: Configuration = {
3435
alias: {
3536
...aliases,
3637
'site.config': resolvedSiteConfigPath,
37-
'@src': path.resolve(process.cwd(), 'src'),
3838
},
3939
extensions: ['.js', '.jsx', '.ts', '.tsx'],
40+
plugins: [new ClosestSrcResolverPlugin()],
4041
},
4142
mode: 'development',
4243
devtool: 'eval-source-map',

tools/webpack/webpack.config.dev.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getStylesheetRule
1515
} from './common-config';
1616

17+
import ClosestSrcResolverPlugin from './plugins/ClosestSrcResolverPlugin';
1718
import getLocalAliases from './utils/getLocalAliases';
1819
import getPublicPath from './utils/getPublicPath';
1920
import getResolvedSiteConfigPath from './utils/getResolvedSiteConfigPath';
@@ -33,9 +34,9 @@ const config: Configuration = {
3334
alias: {
3435
...aliases,
3536
'site.config': resolvedSiteConfigPath,
36-
'@src': path.resolve(process.cwd(), 'src'),
3737
},
3838
extensions: ['.js', '.jsx', '.ts', '.tsx'],
39+
plugins: [new ClosestSrcResolverPlugin()],
3940
},
4041
mode: 'development',
4142
devtool: 'eval-source-map',

tsconfig.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@
22
"extends": "./tools/typescript/tsconfig.json",
33
"compilerOptions": {
44
"rootDir": ".",
5-
"outDir": "dist",
6-
"paths": {
7-
"@src/*": ["./src/*"]
8-
}
5+
"outDir": "dist"
96
},
107
"include": [
118
"runtime/**/*",

0 commit comments

Comments
 (0)