Skip to content

Commit afa0598

Browse files
amannncursoragent
andauthored
fix(useExtracted): Filter common directories from srcPath, if not explicitly requested (#2093)
Ignore `node_modules`, `.next` and `.git` by default, allowing consumers to use `srcPath: './'` in case they're not using a `src` directory. In a future iteration, we might infer these directories from `.gitignore`. Note that you can still use: ``` srcPath: ['./src', './node_modules/@acme/components'], ``` … in case you want to extract from source files inside of such a directory. This PR also contains a fix that allows using trailing slashes in `srcPath` (e.g. `./src/app/`). --------- Co-authored-by: Cursor Agent <[email protected]>
1 parent 96a26af commit afa0598

File tree

5 files changed

+187
-45
lines changed

5 files changed

+187
-45
lines changed

docs/src/pages/docs/usage/plugin.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ If your project is split into multiple folders, you can provide an array of path
128128

129129
```tsx
130130
// Not using a `src` folder
131-
srcPath: ['./app', './components'],
131+
srcPath: './',
132132
```
133133

134134
```tsx
@@ -141,6 +141,8 @@ srcPath: ['./src', '../ui'],
141141
srcPath: ['./src', './node_modules/@acme/components'],
142142
```
143143

144+
Note that the directories `node_modules`, `.next` and `.git` are automatically excluded from extraction, except for if they appear explicitly in the `srcPath` array.
145+
144146
If you want to provide messages along with your package, you can also extract them [manually](/docs/usage/extraction#manual).
145147

146148
**Note:** The `srcPath` option should be used together with [`extract`](#extract) and [`messages`](#messages).

packages/next-intl/src/extractor/ExtractionCompiler.test.tsx

Lines changed: 136 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const filesystem: {
77
project: {
88
src: Record<string, string>;
99
messages: Record<string, string> | undefined;
10+
node_modules?: Record<'@acme', Record<'ui', Record<string, string>>>;
11+
'.next'?: Record<string, Record<string, string>>;
12+
'.git'?: Record<string, Record<string, string>>;
1013
};
1114
} = {
1215
project: {
@@ -15,16 +18,19 @@ const filesystem: {
1518
}
1619
};
1720

18-
describe('json format', () => {
19-
beforeEach(() => {
20-
filesystem.project.src = {};
21-
filesystem.project.messages = {};
22-
fileTimestamps.clear();
23-
watchCallbacks.clear();
24-
mockWatchers.clear();
25-
vi.clearAllMocks();
26-
});
21+
beforeEach(() => {
22+
filesystem.project = {
23+
src: {},
24+
messages: {}
25+
};
26+
delete (filesystem as Record<string, unknown>).ui;
27+
fileTimestamps.clear();
28+
watchCallbacks.clear();
29+
mockWatchers.clear();
30+
vi.clearAllMocks();
31+
});
2732

33+
describe('json format', () => {
2834
function createCompiler() {
2935
return new ExtractionCompiler(
3036
{
@@ -310,28 +316,28 @@ describe('json format', () => {
310316
`
311317
);
312318
expect(filesystem).toMatchInlineSnapshot(`
313-
{
314-
"project": {
315-
"messages": {
316-
"de.json": "{
317-
"+YJVTi": "Hallo!"
318-
}",
319-
"en.json": "{
320-
"+YJVTi": "Hey!"
321-
}",
322-
},
323-
"src": {
324-
"Greeting.tsx": "
325-
import {useExtracted} from 'next-intl';
326-
function Greeting() {
327-
const t = useExtracted();
328-
return <div>{t('Hey!')}</div>;
329-
}
330-
",
319+
{
320+
"project": {
321+
"messages": {
322+
"de.json": "{
323+
"+YJVTi": "Hallo!"
324+
}",
325+
"en.json": "{
326+
"+YJVTi": "Hey!"
327+
}",
328+
},
329+
"src": {
330+
"Greeting.tsx": "
331+
import {useExtracted} from 'next-intl';
332+
function Greeting() {
333+
const t = useExtracted();
334+
return <div>{t('Hey!')}</div>;
335+
}
336+
",
337+
},
331338
},
332-
},
333-
}
334-
`);
339+
}
340+
`);
335341

336342
simulateManualFileEdit(
337343
'messages/de.json',
@@ -692,15 +698,6 @@ describe('json format', () => {
692698
});
693699

694700
describe('po format', () => {
695-
beforeEach(() => {
696-
filesystem.project.src = {};
697-
filesystem.project.messages = {};
698-
fileTimestamps.clear();
699-
watchCallbacks.clear();
700-
mockWatchers.clear();
701-
vi.clearAllMocks();
702-
});
703-
704701
function createCompiler() {
705702
return new ExtractionCompiler(
706703
{
@@ -1229,6 +1226,106 @@ msgstr "Hallo!"
12291226
});
12301227
});
12311228

1229+
describe('`srcPath` filtering', () => {
1230+
beforeEach(() => {
1231+
filesystem.project.src['Greeting.tsx'] = `
1232+
import {useExtracted} from 'next-intl';
1233+
import Panel from '@acme/ui/panel';
1234+
function Greeting() {
1235+
const t = useExtracted();
1236+
return <Panel>{t('Hey!')}</Panel>;
1237+
}
1238+
`;
1239+
1240+
function createNodeModule(moduleName: string) {
1241+
return `
1242+
import {useExtracted} from 'next-intl';
1243+
export default function Module({children}) {
1244+
const t = useExtracted();
1245+
return (
1246+
<div>
1247+
<h1>{t('${moduleName}')}</h1>
1248+
{children}
1249+
</div>
1250+
)
1251+
}
1252+
`;
1253+
}
1254+
1255+
filesystem.project.node_modules = {
1256+
'@acme': {
1257+
ui: {
1258+
'panel.tsx': createNodeModule('panel.source')
1259+
}
1260+
}
1261+
};
1262+
filesystem.project['.next'] = {
1263+
build: {
1264+
'panel.tsx': createNodeModule('panel.compiled')
1265+
}
1266+
};
1267+
filesystem.project['.git'] = {
1268+
config: {
1269+
'panel.tsx': createNodeModule('panel.config')
1270+
}
1271+
};
1272+
});
1273+
1274+
function createCompiler(srcPath: string | Array<string>) {
1275+
return new ExtractionCompiler(
1276+
{
1277+
srcPath,
1278+
sourceLocale: 'en',
1279+
messages: {
1280+
path: './messages',
1281+
format: 'json',
1282+
locales: 'infer'
1283+
}
1284+
},
1285+
{isDevelopment: true, projectRoot: '/project'}
1286+
);
1287+
}
1288+
1289+
it('skips node_modules, .next and .git by default', async () => {
1290+
using compiler = createCompiler('./');
1291+
await compiler.compile(
1292+
'/project/src/Greeting.tsx',
1293+
filesystem.project.src['Greeting.tsx']
1294+
);
1295+
await waitForWriteFileCalls(1);
1296+
expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
1297+
[
1298+
[
1299+
"messages/en.json",
1300+
"{
1301+
"+YJVTi": "Hey!"
1302+
}",
1303+
],
1304+
]
1305+
`);
1306+
});
1307+
1308+
it('includes node_modules if explicitly requested', async () => {
1309+
using compiler = createCompiler(['./', './node_modules/@acme/ui']);
1310+
await compiler.compile(
1311+
'/project/src/Greeting.tsx',
1312+
filesystem.project.src['Greeting.tsx']
1313+
);
1314+
await waitForWriteFileCalls(1);
1315+
expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
1316+
[
1317+
[
1318+
"messages/en.json",
1319+
"{
1320+
"JwjlWH": "panel.source",
1321+
"+YJVTi": "Hey!"
1322+
}",
1323+
],
1324+
]
1325+
`);
1326+
});
1327+
});
1328+
12321329
/**
12331330
* Test utils
12341331
****************************************************************/
Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,46 @@
11
import path from 'path';
22

33
export default class SourceFileFilter {
4-
static readonly EXTENSIONS = ['ts', 'tsx', 'js', 'jsx'];
4+
public static readonly EXTENSIONS = ['ts', 'tsx', 'js', 'jsx'];
5+
6+
// Will not be entered, except if explicitly asked for
7+
// TODO: At some point we should infer these from .gitignore
8+
private static readonly IGNORED_DIRECTORIES = [
9+
'node_modules',
10+
'.next',
11+
'.git'
12+
];
513

614
static isSourceFile(filePath: string) {
715
const ext = path.extname(filePath);
816
return SourceFileFilter.EXTENSIONS.map((cur) => '.' + cur).includes(ext);
917
}
18+
19+
static shouldEnterDirectory(
20+
dirPath: string,
21+
srcPaths: Array<string>
22+
): boolean {
23+
const dirName = path.basename(dirPath);
24+
if (SourceFileFilter.IGNORED_DIRECTORIES.includes(dirName)) {
25+
return SourceFileFilter.isIgnoredDirectoryExplicitlyIncluded(
26+
dirPath,
27+
srcPaths
28+
);
29+
}
30+
return true;
31+
}
32+
33+
private static isIgnoredDirectoryExplicitlyIncluded(
34+
ignoredDirPath: string,
35+
srcPaths: Array<string>
36+
): boolean {
37+
return srcPaths.some((srcPath) =>
38+
SourceFileFilter.isWithinPath(srcPath, ignoredDirPath)
39+
);
40+
}
41+
42+
private static isWithinPath(targetPath: string, basePath: string): boolean {
43+
const relativePath = path.relative(basePath, targetPath);
44+
return relativePath === '' || !relativePath.startsWith('..');
45+
}
1046
}

packages/next-intl/src/extractor/source/SourceFileScanner.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export default class SourceFileScanner {
1212
for (const entry of entries) {
1313
const entryPath = path.join(dir, entry.name);
1414
if (entry.isDirectory()) {
15+
if (!SourceFileFilter.shouldEnterDirectory(entryPath, srcPaths)) {
16+
continue;
17+
}
1518
await SourceFileScanner.walkSourceFiles(entryPath, srcPaths, acc);
1619
} else {
1720
if (SourceFileFilter.isSourceFile(entry.name)) {

packages/next-intl/src/plugin/getNextConfig.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,19 @@ export default function getNextConfig(
148148
throwError('Message extraction requires Next.js 16 or higher.');
149149
}
150150
rules ??= getTurboRules();
151+
const srcPaths = (
152+
Array.isArray(pluginConfig.experimental.srcPath!)
153+
? pluginConfig.experimental.srcPath!
154+
: [pluginConfig.experimental.srcPath!]
155+
).map((srcPath) =>
156+
srcPath.endsWith('/') ? srcPath.slice(0, -1) : srcPath
157+
);
151158
addTurboRule(rules!, `*.{${SourceFileFilter.EXTENSIONS.join(',')}}`, {
152159
loaders: [getExtractMessagesLoaderConfig()],
153160
condition: {
154161
// Note: We don't need `not: 'foreign'`, because this is
155162
// implied by the filter based on `srcPath`.
156-
path:
157-
(Array.isArray(pluginConfig.experimental.srcPath)
158-
? `{${pluginConfig.experimental.srcPath.join(',')}}`
159-
: pluginConfig.experimental.srcPath) + '/**/*',
163+
path: `{${srcPaths.join(',')}}` + '/**/*',
160164
content: /(useExtracted|getExtracted)/
161165
}
162166
});

0 commit comments

Comments
 (0)