diff --git a/.github/workflows/prerelease-canary.yml b/.github/workflows/prerelease-canary.yml
index 5d37793ac..b891b3b04 100644
--- a/.github/workflows/prerelease-canary.yml
+++ b/.github/workflows/prerelease-canary.yml
@@ -16,10 +16,9 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
- registry-url: "https://registry.npmjs.org"
- node-version: 20.x
cache: "pnpm"
- - run: npm install -g npm@latest # Trusted publishers
+ node-version: 20.x
+ registry-url: "https://registry.npmjs.org"
- run: pnpm install
- run: pnpm turbo run build --filter './packages/**'
- run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a35d21e5f..6fb5e1c11 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,9 +16,9 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
- registry-url: 'https://registry.npmjs.org'
+ cache: "pnpm"
node-version: 20.x
- cache: 'pnpm'
+ registry-url: "https://registry.npmjs.org"
- run: npm install -g npm@latest # Trusted publishers
- run: pnpm install
- run: |
@@ -28,5 +28,4 @@ jobs:
if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ') || startsWith(github.event.head_commit.message, 'feat!: ')}}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- NPM_CONFIG_PROVENANCE: true
+ # No NODE_AUTH_TOKEN since we use Trusted Publishers
diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx
index 697e3cf17..78dfcbaa5 100644
--- a/docs/src/pages/docs/usage/configuration.mdx
+++ b/docs/src/pages/docs/usage/configuration.mdx
@@ -1,4 +1,3 @@
-import PartnerContentLink from '@/components/PartnerContentLink';
import Callout from '@/components/Callout';
import {Tabs} from 'nextra/components';
import Details from '@/components/Details';
diff --git a/docs/src/pages/docs/usage/extraction.mdx b/docs/src/pages/docs/usage/extraction.mdx
index b7463027a..abc163401 100644
--- a/docs/src/pages/docs/usage/extraction.mdx
+++ b/docs/src/pages/docs/usage/extraction.mdx
@@ -2,7 +2,7 @@ import Image from 'next/image';
import Callout from '@/components/Callout';
import Details from '@/components/Details';
-# `useExtracted`
+# `useExtracted` (experimental)
As an alternative to managing namespaces and keys manually, `next-intl` provides an additional API that works similar to [`useTranslations`](/docs/usage/translations) but automatically extracts messages from your source files.
@@ -48,7 +48,7 @@ function InlineMessages() {
**Links:**
-- [Blog post](/blog/use-extracted)
+- [Introduction blog post](/blog/use-extracted)
- [Example app](/examples#app-router-extracted)
## Getting started
@@ -73,7 +73,7 @@ const withNextIntl = createNextIntlPlugin({
// Relative path to the directory
path: './messages',
- // Either 'json' or 'po'
+ // Either 'json', 'po', or a custom format (see below)
format: 'json',
// Either 'infer' to automatically detect locales based on
@@ -268,11 +268,11 @@ it('renders', () => {
});
```
-## Formatters
+## Formats [#formats]
-Currently, messages can be extracted as either JSON or PO files. Support for custom formatters is planned for a future release.
+Messages can be extracted as JSON, PO, or with custom file formats.
-When [`messages`](/docs/usage/plugin#messages) is configured, this will also set up a Turbo- or Webpack loader that will ensure loaded messages can be imported as plain JavaScript objects.
+When [`messages`](/docs/usage/plugin#messages) is configured, this will also set up a Turbo- or Webpack loader that will ensure imported messages can be used as plain JavaScript objects.
For example, when using `format: 'po'`, messages can be imported as:
@@ -283,14 +283,14 @@ import {getRequestConfig} from 'next-intl/server';
export default getRequestConfig(async () => {
const locale = 'en';
- // E.g. `{"NhX4DJ": "Hello"}`
+ // E.g. `[{"NhX4DJ": "Hello"}]`
const messages = (await import(`../../messages/${locale}.po`)).default;
// ...
});
```
-### JSON formatter [#formatters-json]
+### JSON format [#formats-json]
When using this option, your messages will look like this:
@@ -300,7 +300,7 @@ When using this option, your messages will look like this:
}
```
-Note that JSON files can only hold pairs of keys and values. To provide more context about a message like file references and descriptions, it's therefore recommended to use [PO files](#po) instead. Another alternative will be to use a custom JSON formatter in the future.
+Note that JSON files can only hold pairs of keys and values. To provide more context about a message like file references and descriptions, it's therefore recommended to use [PO files](#formats-po) instead. Alternatively, you can create a [custom format](#formats-custom) to store additional metadata.
For local editing of JSON messages, you can use e.g. a [VSCode integration](/docs/workflows/vscode-integration) like i18n Ally:
@@ -318,7 +318,7 @@ For local editing of JSON messages, you can use e.g. a [VSCode integration](/doc
-### PO formatter [#formatters-po]
+### PO format [#formats-po]
When using this option, your messages will look like this:
@@ -331,10 +331,58 @@ msgstr "Right"
Besides the message key and the label itself, this format also supports optional descriptions and file references to all modules that consume this message.
-For local editing of PO messages, you can use e.g. a tool like [Poedit](https://poedit.net/) (replacing keys with source text requires a pro license).
+For local editing of .po files, you can use e.g. a tool like [Poedit](https://poedit.net/) (note however that replacing keys with source text requires a pro license).
**Tip:** AI-based translation can be automated with a translation management system like [Crowdin](/docs/workflows/localization-management).
+
+### Custom format [#formats-custom]
+
+To configure a custom format, you need to specify a codec along with an extension.
+
+The codec can be created via `defineCodec` from `next-intl/extractor`:
+
+```tsx filename="./CustomCodec.ts"
+import {defineCodec} from 'next-intl/extractor';
+
+export default defineCodec(() => ({
+ decode(content, context) {
+ // ...
+ },
+
+ encode(messages, context) {
+ // ...
+ },
+
+ toJSONString(content, context) {
+ // ...
+ }
+}));
+```
+
+Then, reference it in your configuration along with an `extension`:
+
+```tsx filename="next.config.ts"
+const withNextIntl = createNextIntlPlugin({
+ experimental: {
+ messages: {
+ format: {
+ codec: './CustomCodec.ts',
+ extension: '.json'
+ }
+ // ...
+ }
+ }
+});
+```
+
+See also the built-in [`codecs`](https://github.com/amannn/next-intl/tree/main/packages/next-intl/src/extractor/format/codecs) for inspiration.
+
+
+
+Node.js supports native TypeScript execution like it's needed for the example above starting with v22.18. If you're on an older version, you should define your codec as a JavaScript file.
+
+
diff --git a/docs/src/pages/docs/usage/plugin.mdx b/docs/src/pages/docs/usage/plugin.mdx
index 54bf657b1..22e2adf3c 100644
--- a/docs/src/pages/docs/usage/plugin.mdx
+++ b/docs/src/pages/docs/usage/plugin.mdx
@@ -93,7 +93,7 @@ const withNextIntl = createNextIntlPlugin({
// Automatically detects locales based on `path`
locales: 'infer',
- // Either 'json' or 'po'
+ // Either 'json', 'po', or a custom format
format: 'json'
}
// ...
@@ -107,7 +107,7 @@ If you want to specify the locales explicitly, you can provide an array for `loc
locales: ['en', 'de'];
```
-Configuring `experimental.messages` will also set up a Turbo- or Webpack loader that will ensure loaded messages can be imported as plain JavaScript objects (see [formatters](/docs/usage/extraction#formatters)).
+Configuring `experimental.messages` will also set up a Turbo- or Webpack loader that will ensure loaded messages can be imported as plain JavaScript objects (see [formats](/docs/usage/extraction#formats)).
**Note:** The `messages` option should be used together with [`extract`](#extract) and [`srcPath`](#src-path).
diff --git a/examples/example-app-router-extracted/src/app/Counter.tsx b/examples/example-app-router-extracted/src/app/Counter.tsx
index 6840a4e83..b0df9f71b 100644
--- a/examples/example-app-router-extracted/src/app/Counter.tsx
+++ b/examples/example-app-router-extracted/src/app/Counter.tsx
@@ -3,7 +3,7 @@
import {useExtracted} from 'next-intl';
import {useState} from 'react';
-export default function Client() {
+export default function Counter() {
const [count, setCount] = useState(1000);
const t = useExtracted();
diff --git a/examples/example-app-router-mixed-routing/playwright.config.ts b/examples/example-app-router-mixed-routing/playwright.config.ts
index 27e95bee1..1c687189a 100644
--- a/examples/example-app-router-mixed-routing/playwright.config.ts
+++ b/examples/example-app-router-mixed-routing/playwright.config.ts
@@ -1,4 +1,3 @@
-/* eslint-disable import/no-extraneous-dependencies */
import type {PlaywrightTestConfig} from '@playwright/test';
import {devices} from '@playwright/test';
@@ -6,7 +5,7 @@ import {devices} from '@playwright/test';
const PORT = process.env.CI ? 3002 : 3000;
const config: PlaywrightTestConfig = {
- retries: process.env.CI ? 1 : 0,
+ retries: process.env.CI ? 2 : 0,
testDir: './tests',
projects: [
{
diff --git a/examples/example-app-router-next-auth/playwright.config.ts b/examples/example-app-router-next-auth/playwright.config.ts
index 6917229e4..97bd85919 100644
--- a/examples/example-app-router-next-auth/playwright.config.ts
+++ b/examples/example-app-router-next-auth/playwright.config.ts
@@ -6,7 +6,7 @@ import {devices} from '@playwright/test';
const PORT = process.env.CI ? 3003 : 3000;
const config: PlaywrightTestConfig = {
- retries: process.env.CI ? 1 : 0,
+ retries: process.env.CI ? 2 : 0,
testDir: './tests',
projects: [
{
diff --git a/examples/example-app-router-playground/playwright.config.ts b/examples/example-app-router-playground/playwright.config.ts
index e6a7e8000..127651b2a 100644
--- a/examples/example-app-router-playground/playwright.config.ts
+++ b/examples/example-app-router-playground/playwright.config.ts
@@ -9,7 +9,7 @@ const PORT = process.env.CI ? 3004 : 3000;
process.env.PORT = PORT.toString();
const config: PlaywrightTestConfig = {
- retries: process.env.CI ? 1 : 0,
+ retries: process.env.CI ? 2 : 0,
testMatch: process.env.TEST_MATCH || 'main.spec.ts',
testDir: './tests',
projects: [
diff --git a/examples/example-app-router-single-locale/playwright.config.ts b/examples/example-app-router-single-locale/playwright.config.ts
index 7c0822fba..cc95edf26 100644
--- a/examples/example-app-router-single-locale/playwright.config.ts
+++ b/examples/example-app-router-single-locale/playwright.config.ts
@@ -6,7 +6,7 @@ import {devices} from '@playwright/test';
const PORT = process.env.CI ? 3005 : 3000;
const config: PlaywrightTestConfig = {
- retries: process.env.CI ? 1 : 0,
+ retries: process.env.CI ? 2 : 0,
testDir: './tests',
projects: [
{
diff --git a/examples/example-app-router-without-i18n-routing/playwright.config.ts b/examples/example-app-router-without-i18n-routing/playwright.config.ts
index dc31793c0..cede4ab15 100644
--- a/examples/example-app-router-without-i18n-routing/playwright.config.ts
+++ b/examples/example-app-router-without-i18n-routing/playwright.config.ts
@@ -6,7 +6,7 @@ import {devices} from '@playwright/test';
const PORT = process.env.CI ? 3006 : 3000;
const config: PlaywrightTestConfig = {
- retries: process.env.CI ? 1 : 0,
+ retries: process.env.CI ? 2 : 0,
testDir: './tests',
projects: [
{
diff --git a/examples/example-app-router/playwright.config.ts b/examples/example-app-router/playwright.config.ts
index ef58b15ba..1de8ec11b 100644
--- a/examples/example-app-router/playwright.config.ts
+++ b/examples/example-app-router/playwright.config.ts
@@ -6,7 +6,7 @@ import {devices} from '@playwright/test';
const PORT = process.env.CI ? 3001 : 3000;
const config: PlaywrightTestConfig = {
- retries: process.env.CI ? 1 : 0,
+ retries: process.env.CI ? 2 : 0,
testDir: './tests',
projects: [
{
diff --git a/package.json b/package.json
index b161bf052..660054030 100644
--- a/package.json
+++ b/package.json
@@ -6,9 +6,9 @@
"build-packages": "turbo run build --filter './packages/**' --filter '!./packages/swc-plugin-extractor'"
},
"devDependencies": {
- "@lerna-lite/cli": "3.9.0",
- "@lerna-lite/publish": "3.9.0",
- "conventional-changelog-conventionalcommits": "7.0.2",
+ "@lerna-lite/cli": "^4.9.4",
+ "@lerna-lite/publish": "^4.9.4",
+ "conventional-changelog-conventionalcommits": "^8.0.0",
"turbo": "^2.4.4"
},
"pnpm": {
diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json
index 5bc644a67..0a0c2cd77 100644
--- a/packages/next-intl/package.json
+++ b/packages/next-intl/package.json
@@ -125,10 +125,11 @@
],
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
+ "@parcel/watcher": "^2.4.1",
"@swc/core": "^1.15.2",
"negotiator": "^1.0.0",
"next-intl-swc-plugin-extractor": "workspace:^",
- "po-parser": "^1.0.2",
+ "po-parser": "^2.0.0",
"use-intl": "workspace:^"
},
"peerDependencies": {
diff --git a/packages/next-intl/rollup.config.js b/packages/next-intl/rollup.config.js
index 6e09c3614..667071985 100644
--- a/packages/next-intl/rollup.config.js
+++ b/packages/next-intl/rollup.config.js
@@ -73,7 +73,8 @@ export default [
output: {
dir: 'dist/cjs/development',
format: 'cjs',
- entryFileNames: '[name].cjs'
+ entryFileNames: '[name].cjs',
+ chunkFileNames: '[name]-[hash].cjs'
}
})
];
diff --git a/packages/next-intl/src/extractor/ExtractionCompiler.test.tsx b/packages/next-intl/src/extractor/ExtractionCompiler.test.tsx
index 8b4f1628d..eb4c85dfd 100644
--- a/packages/next-intl/src/extractor/ExtractionCompiler.test.tsx
+++ b/packages/next-intl/src/extractor/ExtractionCompiler.test.tsx
@@ -28,6 +28,8 @@ beforeEach(() => {
watchCallbacks.clear();
mockWatchers.clear();
readFileInterceptors.clear();
+ parcelWatcherCallbacks.clear();
+ parcelWatcherSubscriptions.clear();
vi.clearAllMocks();
});
@@ -43,11 +45,14 @@ describe('json format', () => {
locales: 'infer'
}
},
- {isDevelopment: true, projectRoot: '/project'}
+ {
+ isDevelopment: true,
+ projectRoot: '/project'
+ }
);
}
- it('saves messages initially', async () => {
+ it('saves messages initially', {timeout: 10000}, async () => {
filesystem.project.src['Greeting.tsx'] = `
import {useExtracted} from 'next-intl';
function Greeting() {
@@ -61,25 +66,26 @@ describe('json format', () => {
};
using compiler = createCompiler();
+ await compiler.extractAll();
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
-
- // Wait for at least 2 writes (might save more due to change detection)
- await waitForWriteFileCalls(2, {atLeast: true});
- expect(filesystem.project.messages).toMatchInlineSnapshot(`
- {
- "de.json": "{
- "+YJVTi": "Hallo!"
- }
- ",
- "en.json": "{
+ await waitForWriteFileCalls(2);
+ expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.json",
+ "{
"+YJVTi": "Hey!"
}
",
+ ],
+ [
+ "messages/de.json",
+ "{
+ "+YJVTi": "Hallo!"
}
+ ",
+ ],
+ ]
`);
});
@@ -98,7 +104,12 @@ describe('json format', () => {
using compiler = createCompiler();
- await compiler.compile(
+ // Initial scan
+ await compiler.extractAll();
+ await waitForWriteFileCalls(2);
+
+ // Update file content
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -109,22 +120,25 @@ describe('json format', () => {
`
);
- // Wait for writes to settle (exact count may vary)
- await waitForWriteFileCalls(4, {atLeast: true});
-
- // Check final state
- expect(filesystem.project.messages).toMatchInlineSnapshot(`
- {
- "de.json": "{
- "OpKKos": ""
- }
- ",
- "en.json": "{
- "OpKKos": "Hello!"
- }
- ",
- }
- `);
+ await waitForWriteFileCalls(4);
+ expect(vi.mocked(fs.writeFile).mock.calls.slice(2)).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.json",
+ "{
+ "OpKKos": "Hello!"
+ }
+ ",
+ ],
+ [
+ "messages/de.json",
+ "{
+ "OpKKos": ""
+ }
+ ",
+ ],
+ ]
+ `);
});
it('removes translations when all messages are removed from a file', async () => {
@@ -141,8 +155,10 @@ describe('json format', () => {
};
using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(2);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
function Greeting() {
@@ -183,8 +199,10 @@ describe('json format', () => {
};
using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(2);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
function Greeting() {
@@ -210,7 +228,7 @@ describe('json format', () => {
]
`);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -257,8 +275,10 @@ describe('json format', () => {
};
using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(2);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -309,8 +329,10 @@ describe('json format', () => {
};
using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(2);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -320,61 +342,57 @@ describe('json format', () => {
}
`
);
- expect(filesystem).toMatchInlineSnapshot(`
- {
- "project": {
- "messages": {
- "de.json": "{
- "+YJVTi": "Hallo!"
+ await waitForWriteFileCalls(4);
+ expect(vi.mocked(fs.writeFile).mock.calls.slice(2)).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.json",
+ "{
+ "OpKKos": "Hello!"
}
",
- "en.json": "{
- "+YJVTi": "Hey!"
+ ],
+ [
+ "messages/de.json",
+ "{
+ "OpKKos": ""
}
",
- },
- "src": {
- "Greeting.tsx": "
- import {useExtracted} from 'next-intl';
- function Greeting() {
- const t = useExtracted();
- return
{t('Hey!')}
;
- }
- ",
- },
- },
- }
+ ],
+ ]
`);
- simulateManualFileEdit(
- 'messages/de.json',
- JSON.stringify({OpKKos: 'Hallo!'})
- );
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
function Greeting() {
const t = useExtracted();
- return {t('Hello!')} {t('Goodbye!')}
;
+ return {t('Hey!')} {t('Goodbye!')}
;
}
`
);
await waitForWriteFileCalls(6);
- expect(filesystem.project.messages).toMatchInlineSnapshot(`
- {
- "de.json": "{
- "NnE1NP": "",
- "OpKKos": "Hallo!"
+ expect(vi.mocked(fs.writeFile).mock.calls.slice(4)).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.json",
+ "{
+ "+YJVTi": "Hey!",
+ "NnE1NP": "Goodbye!"
}
",
- "en.json": "{
- "NnE1NP": "Goodbye!",
- "OpKKos": "Hello!"
+ ],
+ [
+ "messages/de.json",
+ "{
+ "+YJVTi": "Hallo!",
+ "NnE1NP": ""
}
",
- }
+ ],
+ ]
`);
});
@@ -399,16 +417,10 @@ describe('json format', () => {
};
using compiler = createCompiler();
-
- // Kick off compilation
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(2);
- // Remove message from one file
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
function Greeting() {
@@ -417,8 +429,6 @@ describe('json format', () => {
`
);
- await sleep(100);
-
// Note: We write even though catalog content is unchanged because
// Greeting.tsx's messages changed (1→0). The message persists from
// Footer.tsx, so output is identical - this is acceptable overhead.
@@ -456,11 +466,7 @@ describe('json format', () => {
`;
using compiler = createCompiler();
-
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
expect(vi.mocked(fs.mkdir)).toHaveBeenCalledWith('messages', {
recursive: true
@@ -487,10 +493,7 @@ describe('json format', () => {
};
using compiler = createCompiler();
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(1);
@@ -522,10 +525,7 @@ describe('json format', () => {
};
using compiler = createCompiler();
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(1);
@@ -543,9 +543,7 @@ describe('json format', () => {
{timeout: 500}
);
- // vi.mocked(fs.writeFile).mockClear();
-
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -587,17 +585,14 @@ describe('json format', () => {
};
using compiler = createCompiler();
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(3);
delete filesystem.project.messages!['fr.json'];
simulateFileEvent('/project/messages', 'rename', 'fr.json');
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -652,13 +647,13 @@ describe('json format', () => {
locales: ['de', 'fr']
}
},
- {isDevelopment: true, projectRoot: '/project'}
+ {
+ isDevelopment: true,
+ projectRoot: '/project'
+ }
);
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(3);
@@ -702,11 +697,7 @@ describe('json format', () => {
`;
using compiler = createCompiler();
-
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(1);
@@ -747,10 +738,7 @@ describe('json format', () => {
};
using compiler = createCompiler();
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
// Prepare the new locale file
filesystem.project.messages!['fr.json'] = '{"OpKKos": "Bonjour!"}';
@@ -766,9 +754,8 @@ describe('json format', () => {
// Trigger the file change (this starts the loading process)
simulateFileEvent('/project/messages', 'rename', 'fr.json');
- // While loading is pending (stuck in readFile), trigger a compile/save
- // We change the content to ensure `save()` is actually called
- await compiler.compile(
+ // Trigger file update without awaiting - this will queue behind loadCatalogsPromise
+ const updatePromise = simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
filesystem.project.src['Greeting.tsx'] +
`
@@ -785,6 +772,9 @@ describe('json format', () => {
// Allow loading to finish
resolveReadFile?.();
+ // Wait for the file update to complete (it was waiting for loadCatalogsPromise)
+ await updatePromise;
+
// Wait for everything to settle
await sleep(100);
@@ -808,7 +798,10 @@ describe('po format', () => {
locales: 'infer'
}
},
- {isDevelopment: true, projectRoot: '/project'}
+ {
+ isDevelopment: true,
+ projectRoot: '/project'
+ }
);
}
@@ -834,11 +827,7 @@ describe('po format', () => {
};
using compiler = createCompiler();
-
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
[
[
@@ -897,8 +886,10 @@ describe('po format', () => {
};
using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(2);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -972,8 +963,10 @@ describe('po format', () => {
};
using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(2);
- await compiler.compile(
+ await simulateSourceFileCreate(
'/project/src/Footer.tsx',
`
import {useExtracted} from 'next-intl';
@@ -1039,11 +1032,8 @@ describe('po format', () => {
using compiler = createCompiler();
- // First compile: only Greeting.tsx has the message
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ // First compile: Only Greeting.tsx has the message
+ await compiler.extractAll();
await waitForWriteFileCalls(2);
expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
@@ -1082,7 +1072,7 @@ describe('po format', () => {
`);
// Second compile: Footer.tsx also uses the same message
- await compiler.compile(
+ await simulateSourceFileCreate(
'/project/src/Footer.tsx',
`
import {useExtracted} from 'next-intl';
@@ -1132,109 +1122,66 @@ describe('po format', () => {
`);
});
- it('supports namespaces', async () => {
+ it('removes references when a message is dropped from a single file', async () => {
filesystem.project.src['Greeting.tsx'] = `
import {useExtracted} from 'next-intl';
function Greeting() {
- const t = useExtracted('ui');
- return {t('Hello!')}
;
+ const t = useExtracted();
+ return (
+
+ {t('Hey!')}
+ {t('Howdy!')}
+
+ );
}
`;
-
- using compiler = createCompiler();
-
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
-
- await waitForWriteFileCalls(1);
- expect(vi.mocked(fs.writeFile).mock.calls[0]).toMatchInlineSnapshot(`
- [
- "messages/en.po",
- "msgid ""
- msgstr ""
- "Language: en\\n"
- "Content-Type: text/plain; charset=utf-8\\n"
- "Content-Transfer-Encoding: 8bit\\n"
- "X-Generator: next-intl\\n"
- "X-Crowdin-SourceKey: msgstr\\n"
-
- #: src/Greeting.tsx
- msgctxt "ui"
- msgid "OpKKos"
- msgstr "Hello!"
- ",
- ]
- `);
- });
-
- it('retains metadata when saving back to file', async () => {
- filesystem.project.src['Greeting.tsx'] = `
+ filesystem.project.src['Footer.tsx'] = `
import {useExtracted} from 'next-intl';
- function Greeting() {
+ function Footer() {
const t = useExtracted();
return {t('Hey!')}
;
}
`;
filesystem.project.messages = {
- 'en.po': `msgid ""
-msgstr ""
-"POT-Creation-Date: 2025-10-27 16:00+0000\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"X-Generator: some-po-editor\n"
-"X-Something-Else: test\n"
-"Language: en\n"
-
-#: src/Greeting.tsx:4
-msgid "+YJVTi"
-msgstr "Hey!"
-`,
- 'de.po': `msgid ""
-msgstr ""
-"POT-Creation-Date: 2025-10-27 16:00+0000\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Language: de\n"
-
-#: src/Greeting.tsx:4
-msgid "+YJVTi"
-msgstr "Hallo!"
-`
+ 'en.po': '',
+ 'de.po': ''
};
using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(2);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
function Greeting() {
const t = useExtracted();
- return {t('Hello!')}
;
+ return {t('Howdy!')}
;
}
`
);
await waitForWriteFileCalls(4);
- expect(vi.mocked(fs.writeFile).mock.calls.slice(2)).toMatchInlineSnapshot(`
+ expect(vi.mocked(fs.writeFile).mock.calls.slice(-2)).toMatchInlineSnapshot(`
[
[
"messages/en.po",
"msgid ""
msgstr ""
"Language: en\\n"
- "Content-Type: text/plain; charset=UTF-8\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
"Content-Transfer-Encoding: 8bit\\n"
- "X-Generator: some-po-editor\\n"
+ "X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- "POT-Creation-Date: 2025-10-27 16:00+0000\\n"
- "MIME-Version: 1.0\\n"
- "X-Something-Else: test\\n"
+
+ #: src/Footer.tsx
+ msgid "+YJVTi"
+ msgstr "Hey!"
#: src/Greeting.tsx
- msgid "OpKKos"
- msgstr "Hello!"
+ msgid "4xqPlJ"
+ msgstr "Howdy!"
",
],
[
@@ -1242,14 +1189,17 @@ msgstr "Hallo!"
"msgid ""
msgstr ""
"Language: de\\n"
- "Content-Type: text/plain; charset=UTF-8\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
"Content-Transfer-Encoding: 8bit\\n"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- "POT-Creation-Date: 2025-10-27 16:00+0000\\n"
+
+ #: src/Footer.tsx
+ msgid "+YJVTi"
+ msgstr ""
#: src/Greeting.tsx
- msgid "OpKKos"
+ msgid "4xqPlJ"
msgstr ""
",
],
@@ -1257,37 +1207,10 @@ msgstr "Hallo!"
`);
});
- it('sorts messages by reference path', async () => {
- using compiler = createCompiler();
-
- await compiler.compile(
- '/project/src/components/Header.tsx',
- `
- import {useExtracted} from 'next-intl';
- export default function Header() {
- const t = useExtracted();
- return {t('Welcome')}
;
- }
- `
- );
-
- await compiler.compile(
- '/project/src/app/page.tsx',
- `
- import {useExtracted} from 'next-intl';
- export default function Page() {
- const t = useExtracted();
- return {t('Hello')}
;
- }
- `
- );
-
- await waitForWriteFileCalls(3);
-
- expect(vi.mocked(fs.writeFile).mock.calls.at(-1)).toMatchInlineSnapshot(`
- [
- "messages/en.po",
- "msgid ""
+ it('removes obsolete messages during build', async () => {
+ filesystem.project.messages = {
+ 'en.po': `
+ msgid ""
msgstr ""
"Language: en\\n"
"Content-Type: text/plain; charset=utf-8\\n"
@@ -1295,66 +1218,524 @@ msgstr "Hallo!"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/app/page.tsx
- msgid "NhX4DJ"
- msgstr "Hello"
+ #: src/component-a.tsx
+ msgid "OpKKos"
+ msgstr "Hello!"
+ `,
+ 'de.po': `
+ msgid ""
+ msgstr ""
+ "Language: de\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
- #: src/components/Header.tsx
- msgid "PwaN2o"
- msgstr "Welcome"
+ #: src/component-a.tsx
+ msgid "OpKKos"
+ msgstr "Hallo!"
+ `
+ };
+ filesystem.project.src['component-b.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function Component() {
+ const t = useExtracted();
+ return {t('Howdy!')}
;
+ }
+ `;
+
+ using compiler = new ExtractionCompiler(
+ {
+ srcPath: './src',
+ sourceLocale: 'en',
+ messages: {
+ path: './messages',
+ format: 'po',
+ locales: 'infer'
+ }
+ },
+ {
+ isDevelopment: false,
+ projectRoot: '/project'
+ }
+ );
+
+ await compiler.extractAll();
+
+ await waitForWriteFileCalls(2);
+ expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.po",
+ "msgid ""
+ msgstr ""
+ "Language: en\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/component-b.tsx
+ msgid "4xqPlJ"
+ msgstr "Howdy!"
",
+ ],
+ [
+ "messages/de.po",
+ "msgid ""
+ msgstr ""
+ "Language: de\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/component-b.tsx
+ msgid "4xqPlJ"
+ msgstr ""
+ ",
+ ],
]
`);
});
- it('sorts messages by reference path when files are compiled out of order', async () => {
+ it('removes messages when a file is deleted during dev', async () => {
+ filesystem.project.src['component-a.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function ComponentA() {
+ const t = useExtracted();
+ return {t('Hello!')}
;
+ }
+ `;
+ filesystem.project.messages = {};
+
using compiler = createCompiler();
+ await compiler.extractAll();
- await compiler.compile(
- '/project/src/a.tsx',
+ await waitForWriteFileCalls(1);
+ expect(vi.mocked(fs.writeFile).mock.calls[0][1]).toContain('Hello!');
+
+ await simulateSourceFileCreate(
+ '/project/src/component-b.tsx',
`
import {useExtracted} from 'next-intl';
- export default function A() {
+ function ComponentB() {
const t = useExtracted();
- return {t('Message A')}
;
+ return {t('Howdy!')}
;
}
`
);
- await compiler.compile(
- '/project/src/d.tsx',
+ await waitForWriteFileCalls(2);
+ expect(vi.mocked(fs.writeFile).mock.calls[1][1]).toContain('Howdy!');
+
+ await simulateSourceFileDelete('/project/src/component-b.tsx');
+
+ // TODO: Trigger file removal
+
+ await waitForWriteFileCalls(3);
+ expect(vi.mocked(fs.writeFile).mock.calls.at(-1)?.[1]).not.toContain(
+ 'component-b.tsx'
+ );
+ });
+
+ it('removes obsolete references after a file rename during build', async () => {
+ filesystem.project.messages = {
+ 'en.po': `
+ msgid ""
+ msgstr ""
+ "Language: en\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/component-a.tsx
+ msgid "OpKKos"
+ msgstr "Hello!"
+ `,
+ 'de.po': `
+ msgid ""
+ msgstr ""
+ "Language: de\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/component-a.tsx
+ msgid "OpKKos"
+ msgstr "Hallo!"
`
+ };
+ filesystem.project.src['component-b.tsx'] = `
import {useExtracted} from 'next-intl';
- export default function D() {
+ function Component() {
const t = useExtracted();
- return {t('Message B')}
;
+ return {t('Hello!')}
;
}
- `
+ `;
+
+ using compiler = new ExtractionCompiler(
+ {
+ srcPath: './src',
+ sourceLocale: 'en',
+ messages: {
+ path: './messages',
+ format: 'po',
+ locales: 'infer'
+ }
+ },
+ {
+ isDevelopment: false,
+ projectRoot: '/project'
+ }
);
- await compiler.compile(
- '/project/src/c.tsx',
- `
+ await compiler.extractAll();
+ await waitForWriteFileCalls(2);
+ expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.po",
+ "msgid ""
+ msgstr ""
+ "Language: en\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/component-b.tsx
+ msgid "OpKKos"
+ msgstr "Hello!"
+ ",
+ ],
+ [
+ "messages/de.po",
+ "msgid ""
+ msgstr ""
+ "Language: de\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/component-b.tsx
+ msgid "OpKKos"
+ msgstr "Hallo!"
+ ",
+ ],
+ ]
+ `);
+ });
+
+ it('removes obsolete references after a file rename during dev if create fires before delete', async () => {
+ const file = `
import {useExtracted} from 'next-intl';
- export default function C() {
+ function Component() {
const t = useExtracted();
- return {t('Message C')}
;
+ return {t('Hello!')}
;
}
- `
+ `;
+ filesystem.project.src['component-a.tsx'] = file;
+ filesystem.project.messages = {
+ 'en.po': '',
+ 'de.po': ''
+ };
+
+ using compiler = createCompiler();
+ await compiler.extractAll();
+
+ // Reference to component-a.tsx is written
+ await waitForWriteFileCalls(2);
+ expect(vi.mocked(fs.writeFile).mock.calls.at(-1)?.[1]).toContain(
+ 'src/component-a.tsx'
);
- await compiler.compile(
- '/project/src/b.tsx',
- `
+ await simulateSourceFileCreate('/project/src/component-b.tsx', file);
+ await simulateSourceFileDelete('/project/src/component-a.tsx');
+
+ await waitForWriteFileCalls(6);
+
+ expect(vi.mocked(fs.writeFile).mock.calls.slice(4)).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.po",
+ "msgid ""
+ msgstr ""
+ "Language: en\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/component-b.tsx
+ msgid "OpKKos"
+ msgstr "Hello!"
+ ",
+ ],
+ [
+ "messages/de.po",
+ "msgid ""
+ msgstr ""
+ "Language: de\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/component-b.tsx
+ msgid "OpKKos"
+ msgstr ""
+ ",
+ ],
+ ]
+ `);
+ });
+
+ it('removes obsolete references after a file rename during dev if delete fires before create', async () => {
+ const file = `
import {useExtracted} from 'next-intl';
- export default function B() {
+ function Component() {
const t = useExtracted();
- return {t('Message B')}
;
+ return {t('Hello!')}
;
}
- `
+ `;
+ filesystem.project.src['component-a.tsx'] = file;
+ filesystem.project.messages = {
+ 'en.po': '',
+ 'de.po': ''
+ };
+
+ using compiler = createCompiler();
+ await compiler.extractAll();
+
+ // Reference to component-a.tsx is written
+ await waitForWriteFileCalls(2);
+ expect(vi.mocked(fs.writeFile).mock.calls.at(-1)?.[1]).toContain(
+ 'src/component-a.tsx'
);
- await waitForWriteFileCalls(5);
+ await simulateSourceFileDelete('/project/src/component-a.tsx');
+ await simulateSourceFileCreate('/project/src/component-b.tsx', file);
+
+ await waitForWriteFileCalls(6);
+
+ expect(vi.mocked(fs.writeFile).mock.calls.slice(4)).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.po",
+ "msgid ""
+ msgstr ""
+ "Language: en\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/component-b.tsx
+ msgid "OpKKos"
+ msgstr "Hello!"
+ ",
+ ],
+ [
+ "messages/de.po",
+ "msgid ""
+ msgstr ""
+ "Language: de\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/component-b.tsx
+ msgid "OpKKos"
+ msgstr ""
+ ",
+ ],
+ ]
+ `);
+ });
+
+ it('supports namespaces', async () => {
+ filesystem.project.src['Greeting.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function Greeting() {
+ const t = useExtracted('ui');
+ return {t('Hello!')}
;
+ }
+ `;
+
+ using compiler = createCompiler();
+ await compiler.extractAll();
+
+ await waitForWriteFileCalls(1);
+ expect(vi.mocked(fs.writeFile).mock.calls[0]).toMatchInlineSnapshot(`
+ [
+ "messages/en.po",
+ "msgid ""
+ msgstr ""
+ "Language: en\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/Greeting.tsx
+ msgctxt "ui"
+ msgid "OpKKos"
+ msgstr "Hello!"
+ ",
+ ]
+ `);
+ });
+
+ it('retains metadata when saving back to file', async () => {
+ filesystem.project.src['Greeting.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function Greeting() {
+ const t = useExtracted();
+ return {t('Hey!')}
;
+ }
+ `;
+ filesystem.project.messages = {
+ 'en.po': `msgid ""
+msgstr ""
+"POT-Creation-Date: 2025-10-27 16:00+0000\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"X-Generator: some-po-editor\n"
+"X-Something-Else: test\n"
+"Language: en\n"
+
+#: src/Greeting.tsx:4
+msgid "+YJVTi"
+msgstr "Hey!"
+`,
+ 'de.po': `msgid ""
+msgstr ""
+"POT-Creation-Date: 2025-10-27 16:00+0000\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: de\n"
+
+#: src/Greeting.tsx:4
+msgid "+YJVTi"
+msgstr "Hallo!"
+`
+ };
+
+ using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(2);
+
+ await simulateSourceFileUpdate(
+ '/project/src/Greeting.tsx',
+ `
+ import {useExtracted} from 'next-intl';
+ function Greeting() {
+ const t = useExtracted();
+ return {t('Hello!')}
;
+ }
+ `
+ );
+
+ await waitForWriteFileCalls(4);
+ expect(vi.mocked(fs.writeFile).mock.calls.slice(2)).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.po",
+ "msgid ""
+ msgstr ""
+ "Language: en\\n"
+ "Content-Type: text/plain; charset=UTF-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: some-po-editor\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+ "POT-Creation-Date: 2025-10-27 16:00+0000\\n"
+ "MIME-Version: 1.0\\n"
+ "X-Something-Else: test\\n"
+
+ #: src/Greeting.tsx
+ msgid "OpKKos"
+ msgstr "Hello!"
+ ",
+ ],
+ [
+ "messages/de.po",
+ "msgid ""
+ msgstr ""
+ "Language: de\\n"
+ "Content-Type: text/plain; charset=UTF-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+ "POT-Creation-Date: 2025-10-27 16:00+0000\\n"
+
+ #: src/Greeting.tsx
+ msgid "OpKKos"
+ msgstr ""
+ ",
+ ],
+ ]
+ `);
+ });
+
+ it('sorts messages by reference path', async () => {
+ filesystem.project.src['components/Header.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ export default function Header() {
+ const t = useExtracted();
+ return {t('Welcome')}
;
+ }
+ `;
+ filesystem.project.src['app/page.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ export default function Page() {
+ const t = useExtracted();
+ return {t('Hello')}
;
+ }
+ `;
+
+ using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(1);
+
+ expect(vi.mocked(fs.writeFile).mock.calls.at(-1)).toMatchInlineSnapshot(`
+ [
+ "messages/en.po",
+ "msgid ""
+ msgstr ""
+ "Language: en\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/app/page.tsx
+ msgid "NhX4DJ"
+ msgstr "Hello"
+
+ #: src/components/Header.tsx
+ msgid "PwaN2o"
+ msgstr "Welcome"
+ ",
+ ]
+ `);
+ });
+
+ it('sorts messages by reference path when files are compiled out of order', async () => {
+ using compiler = createCompiler();
+
+ filesystem.project.src['a.tsx'] = createFile('A', 'Message A');
+ await compiler.extractAll();
+ filesystem.project.src['d.tsx'] = createFile('D', 'Message B');
+ await compiler.extractAll();
+ filesystem.project.src['c.tsx'] = createFile('C', 'Message C');
+ await compiler.extractAll();
+ filesystem.project.src['b.tsx'] = createFile('B', 'Message B');
+ await compiler.extractAll();
+ await waitForWriteFileCalls(4);
expect(vi.mocked(fs.writeFile).mock.calls.at(-1)).toMatchInlineSnapshot(`
[
@@ -1395,11 +1776,7 @@ msgstr "Hallo!"
`;
using compiler = createCompiler();
-
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(1);
@@ -1456,11 +1833,7 @@ msgstr "Hallo!"
};
using compiler = createCompiler();
-
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(2);
expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
@@ -1525,11 +1898,7 @@ msgstr "Hallo!"
};
using compiler = createCompiler();
-
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(2);
@@ -1547,8 +1916,7 @@ msgstr "Hey!"
`
);
- // Trigger recompile with source change
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -1605,7 +1973,7 @@ msgstr "World"
`
);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -1670,7 +2038,7 @@ msgstr ""
`
);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -1743,7 +2111,7 @@ msgstr ""
`
);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -1812,11 +2180,7 @@ msgstr ""
};
using compiler = createCompiler();
-
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(1);
@@ -1833,7 +2197,7 @@ msgstr "Hey!"
`
);
- await compiler.compile(
+ await simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
`
import {useExtracted} from 'next-intl';
@@ -1895,10 +2259,7 @@ msgstr "Hey!"
};
using compiler = createCompiler();
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
let resolveReadFile: (() => void) | undefined;
const readFilePromise = new Promise((resolve) => {
@@ -1911,11 +2272,7 @@ msgstr "Hey!"
simulateFileEvent('/project/messages', 'rename', 'de.po');
simulateFileEvent('/project/messages', 'rename', 'en.po');
- // Wait a bit to ensure onLocalesChange has started and created the reload promise
- await sleep(50);
-
- // While loading is pending (stuck in readFile), trigger a compile/save
- await compiler.compile(
+ const updatePromise = simulateSourceFileUpdate(
'/project/src/Greeting.tsx',
filesystem.project.src['Greeting.tsx'] +
`
@@ -1930,7 +2287,8 @@ msgstr "Hey!"
resolveReadFile?.();
- // Wait for everything to settle
+ await updatePromise;
+
await sleep(100);
await waitForWriteFileCalls(4);
@@ -1984,36 +2342,134 @@ msgstr "Hey!"
msgid "nm/7yQ"
msgstr "Hi!"
- #. This is a description
+ #. This is a description
+ #: src/Greeting.tsx
+ #, c-format
+ msgid "OpKKos"
+ msgstr "Hello!"
+ ",
+ ],
+ [
+ "messages/de.po",
+ "msgid ""
+ msgstr ""
+ "Language: de\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+ "X-Crowdin-SourceKey: msgstr\\n"
+
+ #: src/Greeting.tsx
+ msgid "nm/7yQ"
+ msgstr ""
+
+ #. This is a description
+ #: src/Greeting.tsx
+ #, fuzzy
+ msgid "OpKKos"
+ msgstr "Hallo!"
+ ",
+ ],
+ ]
+ `);
+ });
+
+ it('propagates read errors instead of silently returning empty (prevents translation wipes)', async () => {
+ filesystem.project.src['Greeting.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function Greeting() {
+ const t = useExtracted();
+ return {t('Hello!')}
;
+ }
+ `;
+ filesystem.project.messages = {
+ 'en.po': `
+ #: src/Greeting.tsx
+ msgid "OpKKos"
+ msgstr "Hello!"
+ `,
+ 'de.po': `
+ #: src/Greeting.tsx
+ msgid "OpKKos"
+ msgstr "Hallo!"
+ `
+ };
+
+ // Intercept reading to simulate a corruption/I/O error
+ // (not ENOENT - file exists but can't be read)
+ let rejectReadFile: ((error: Error) => void) | undefined;
+ const readFilePromise = new Promise((_, reject) => {
+ rejectReadFile = reject;
+ });
+
+ readFileInterceptors.set('de.po', () => readFilePromise);
+
+ using compiler = createCompiler();
+ await sleep(50);
+
+ const ioError = new Error('EACCES: permission denied');
+ (ioError as NodeJS.ErrnoException).code = 'EACCES';
+ rejectReadFile?.(ioError);
+
+ await expect(compiler.extractAll()).rejects.toThrow(
+ 'Error while reading de.po:\n> Error: EACCES: permission denied'
+ );
+ });
+
+ it('returns empty array only for ENOENT (file not found) errors', async () => {
+ filesystem.project.src['Greeting.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function Greeting() {
+ const t = useExtracted();
+ return {t('Hello!')}
;
+ }
+ `;
+
+ // Only source locale exists, target locale doesn't exist yet
+ filesystem.project.messages = {
+ 'en.po': `
#: src/Greeting.tsx
- #, c-format
msgid "OpKKos"
msgstr "Hello!"
- ",
- ],
- [
- "messages/de.po",
- "msgid ""
- msgstr ""
- "Language: de\\n"
- "Content-Type: text/plain; charset=utf-8\\n"
- "Content-Transfer-Encoding: 8bit\\n"
- "X-Generator: next-intl\\n"
- "X-Crowdin-SourceKey: msgstr\\n"
+ `
+ };
- #: src/Greeting.tsx
- msgid "nm/7yQ"
- msgstr ""
+ using compiler = createCompiler();
+ await compiler.extractAll();
- #. This is a description
+ // Should succeed and create empty target locale
+ await waitForWriteFileCalls(1);
+ expect(vi.mocked(fs.writeFile).mock.calls[0][0]).toBe('messages/en.po');
+ });
+
+ it('propagates parser errors from corrupted/truncated files (prevents translation wipes)', async () => {
+ filesystem.project.src['Greeting.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function Greeting() {
+ const t = useExtracted();
+ return {t('Hello!')}
;
+ }
+ `;
+ filesystem.project.messages = {
+ 'en.po': `
#: src/Greeting.tsx
- #, fuzzy
msgid "OpKKos"
- msgstr "Hallo!"
- ",
- ],
- ]
- `);
+ msgstr "Hello!"
+ `,
+ // Simulates a truncated file read during concurrent write
+ // (file was truncated but read succeeded with partial content)
+ 'de.po': `
+ #: src/Greeting.tsx
+ msgid "OpKKos"
+ msgstr "Hal`
+ // ↑ Truncated mid-write, parser will fail
+ };
+
+ using compiler = createCompiler();
+
+ await expect(compiler.extractAll()).rejects.toThrow(
+ 'Error while decoding de.po:\n> Error: Incomplete quoted string:\n> "Hal'
+ );
});
});
@@ -2073,16 +2529,16 @@ describe('`srcPath` filtering', () => {
locales: 'infer'
}
},
- {isDevelopment: true, projectRoot: '/project'}
+ {
+ isDevelopment: true,
+ projectRoot: '/project'
+ }
);
}
it('skips node_modules, .next and .git by default', async () => {
using compiler = createCompiler('./');
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(1);
expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
[
@@ -2099,10 +2555,7 @@ describe('`srcPath` filtering', () => {
it('includes node_modules if explicitly requested', async () => {
using compiler = createCompiler(['./', './node_modules/@acme/ui']);
- await compiler.compile(
- '/project/src/Greeting.tsx',
- filesystem.project.src['Greeting.tsx']
- );
+ await compiler.extractAll();
await waitForWriteFileCalls(1);
expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
[
@@ -2119,10 +2572,210 @@ describe('`srcPath` filtering', () => {
});
});
+describe('custom format', () => {
+ it('supports a structured json custom format with codecs', async () => {
+ filesystem.project.messages = {
+ 'en.json': JSON.stringify(
+ {
+ 'ui.wESdnU': {message: 'Click me', description: 'Button label'}
+ },
+ null,
+ 2
+ )
+ };
+ filesystem.project.src['Button.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function Button() {
+ const t = useExtracted('ui');
+ return (
+
+ );
+ }
+ `;
+
+ using compiler = new ExtractionCompiler(
+ {
+ srcPath: './src',
+ sourceLocale: 'en',
+ messages: {
+ path: './messages',
+ format: {
+ codec: path.resolve(
+ __dirname,
+ 'format/codecs/fixtures/JSONCodecStructured.tsx'
+ ),
+ extension: '.json'
+ },
+ locales: 'infer'
+ }
+ },
+ {
+ isDevelopment: true,
+ projectRoot: '/project'
+ }
+ );
+
+ await compiler.extractAll();
+ await waitForWriteFileCalls(1);
+
+ expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.json",
+ "{
+ "ui.wESdnU": {
+ "message": "Click me",
+ "description": "Button label"
+ },
+ "ui.wSZR47": {
+ "message": "Submit"
+ }
+ }
+ ",
+ ],
+ ]
+ `);
+ });
+
+ it('supports a custom PO format that uses source messages as msgid', async () => {
+ filesystem.project.src['Greeting.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function Greeting() {
+ const t = useExtracted();
+ return {t('Hello!')}
;
+ }
+ `;
+ filesystem.project.messages = {
+ 'en.po': `
+ #: src/Greeting.tsx
+ msgctxt "OpKKos"
+ msgid "Hello!"
+ msgstr "Hello!"
+ `,
+ 'de.po': `
+ #: src/Greeting.tsx
+ msgctxt "OpKKos"
+ msgid "Hello!"
+ msgstr "Hallo!"
+ `
+ };
+
+ using compiler = new ExtractionCompiler(
+ {
+ srcPath: './src',
+ sourceLocale: 'en',
+ messages: {
+ path: './messages',
+ format: {
+ codec: path.resolve(
+ __dirname,
+ 'format/codecs/fixtures/POCodecSourceMessageKey.tsx'
+ ),
+ extension: '.po'
+ },
+ locales: 'infer'
+ }
+ },
+ {
+ isDevelopment: true,
+ projectRoot: '/project'
+ }
+ );
+
+ filesystem.project.src['Greeting.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function Greeting() {
+ const t = useExtracted();
+ return {t('Hello!')}
;
+ }
+ function Error() {
+ const t = useExtracted('misc');
+ return (
+
+ {t('The code you entered is incorrect. Please try again or contact support@example.com.')}
+ {t("Checking if you're logged in.")}
+
+ );
+ }
+ `;
+
+ await compiler.extractAll();
+
+ await waitForWriteFileCalls(2);
+ expect(vi.mocked(fs.writeFile).mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "messages/en.po",
+ "msgid ""
+ msgstr ""
+ "Language: en\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+
+ #: src/Greeting.tsx
+ msgctxt "misc.Fp6Fab"
+ msgid "Checking if you're logged in."
+ msgstr "Checking if you're logged in."
+
+ #: src/Greeting.tsx
+ msgctxt "misc.l6ZjWT"
+ msgid "The code you entered is incorrect. Please try again or contact support@example.com."
+ msgstr "The code you entered is incorrect. Please try again or contact support@example.com."
+
+ #: src/Greeting.tsx
+ msgctxt "OpKKos"
+ msgid "Hello!"
+ msgstr "Hello!"
+ ",
+ ],
+ [
+ "messages/de.po",
+ "msgid ""
+ msgstr ""
+ "Language: de\\n"
+ "Content-Type: text/plain; charset=utf-8\\n"
+ "Content-Transfer-Encoding: 8bit\\n"
+ "X-Generator: next-intl\\n"
+
+ #: src/Greeting.tsx
+ msgctxt "misc.Fp6Fab"
+ msgid "Checking if you're logged in."
+ msgstr ""
+
+ #: src/Greeting.tsx
+ msgctxt "misc.l6ZjWT"
+ msgid "The code you entered is incorrect. Please try again or contact support@example.com."
+ msgstr ""
+
+ #: src/Greeting.tsx
+ msgctxt "OpKKos"
+ msgid "Hello!"
+ msgstr "Hallo!"
+ ",
+ ],
+ ]
+ `);
+ });
+});
+
/**
* Test utils
****************************************************************/
+function createFile(componentName: string, message: string) {
+ return `
+ import {useExtracted} from 'next-intl';
+ export default function ${componentName}() {
+ const t = useExtracted();
+ return {t('${message}')}
;
+ }
+ `;
+}
+
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -2157,7 +2810,31 @@ function getNestedValue(obj: any, pathname: string): any {
pathParts = ['project', ...pathname.split('/')];
}
- return pathParts.reduce((current, key) => current?.[key], obj);
+ // Try nested structure first
+ const result = pathParts.reduce((current, key) => current?.[key], obj);
+ if (result !== undefined) {
+ return result;
+ }
+
+ // Fallback: check for flat keys with slashes (e.g., filesystem.project.src['components/Header.tsx'])
+ // This handles cases where readdir returns keys with slashes but readFile expects nested structure
+ // We need to check parents at different levels to find where the flat key might be stored
+ for (let i = pathParts.length - 1; i >= 1; i--) {
+ const parentPath = pathParts.slice(0, i);
+ const remainingPath = pathParts.slice(i);
+ const parent = parentPath.reduce((current, key) => current?.[key], obj);
+ if (parent && typeof parent === 'object') {
+ const flatKey = remainingPath.join('/');
+ // Check for exact match or key ending with the remaining path
+ for (const key of Object.keys(parent)) {
+ if (key === flatKey || key.endsWith('/' + flatKey)) {
+ return parent[key];
+ }
+ }
+ }
+ }
+
+ return undefined;
}
function setNestedValue(obj: any, pathname: string, value: string): void {
@@ -2249,6 +2926,12 @@ const watchCallbacks: Map void> =
new Map();
const mockWatchers: Map = new Map();
const readFileInterceptors = new Map Promise>();
+const parcelWatcherCallbacks: Map<
+ string,
+ (err: Error | null, events: Array<{type: string; path: string}>) => void
+> = new Map();
+const parcelWatcherSubscriptions: Map}> =
+ new Map();
function simulateFileEvent(
dirPath: string,
@@ -2294,6 +2977,143 @@ function simulateFileEvent(
}
}
+async function simulateSourceFileCreate(
+ filePath: string,
+ content: string
+): Promise {
+ setNestedValue(filesystem, filePath, content);
+ fileTimestamps.set(filePath, new Date());
+
+ // Find matching watcher callback
+ const normalizedPath = path.resolve(filePath);
+ const dirPath = path.dirname(normalizedPath);
+
+ const pathsToTry = [
+ dirPath,
+ path.resolve(dirPath),
+ path.join(process.cwd(), dirPath),
+ dirPath.replace(/\/$/, ''),
+ path.resolve(dirPath).replace(/\/$/, '')
+ ];
+
+ for (const testPath of pathsToTry) {
+ const callback = parcelWatcherCallbacks.get(testPath);
+ if (callback) {
+ callback(null, [{type: 'create', path: normalizedPath}]);
+ return;
+ }
+ }
+}
+
+async function simulateSourceFileUpdate(
+ filePath: string,
+ content: string
+): Promise {
+ setNestedValue(filesystem, filePath, content);
+ fileTimestamps.set(filePath, new Date());
+
+ // Find matching watcher callback
+ const normalizedPath = path.resolve(filePath);
+ const dirPath = path.dirname(normalizedPath);
+
+ const pathsToTry = [
+ dirPath,
+ path.resolve(dirPath),
+ path.join(process.cwd(), dirPath),
+ dirPath.replace(/\/$/, ''),
+ path.resolve(dirPath).replace(/\/$/, '')
+ ];
+
+ for (const testPath of pathsToTry) {
+ const callback = parcelWatcherCallbacks.get(testPath);
+ if (callback) {
+ callback(null, [{type: 'update', path: normalizedPath}]);
+ return;
+ }
+ }
+}
+
+async function simulateSourceFileDelete(filePath: string): Promise {
+ const normalizedPath = path.resolve(filePath);
+ const dirPath = path.dirname(normalizedPath);
+
+ // Remove from filesystem
+ const pathParts = normalizedPath
+ .replace(/^\//, '')
+ .split('/')
+ .filter(Boolean);
+ let current: any = filesystem;
+ for (let i = 0; i < pathParts.length - 1; i++) {
+ if (current[pathParts[i]]) {
+ current = current[pathParts[i]];
+ } else {
+ return; // Already deleted
+ }
+ }
+ delete current[pathParts[pathParts.length - 1]];
+ fileTimestamps.delete(normalizedPath);
+
+ // Find matching watcher callback
+ const pathsToTry = [
+ dirPath,
+ path.resolve(dirPath),
+ path.join(process.cwd(), dirPath),
+ dirPath.replace(/\/$/, ''),
+ path.resolve(dirPath).replace(/\/$/, '')
+ ];
+
+ for (const testPath of pathsToTry) {
+ const callback = parcelWatcherCallbacks.get(testPath);
+ if (callback) {
+ callback(null, [{type: 'delete', path: normalizedPath}]);
+ return;
+ }
+ }
+}
+
+vi.mock('@parcel/watcher', () => ({
+ subscribe: vi.fn(
+ async (
+ rootPath: string,
+ callback: (
+ err: Error | null,
+ events: Array<{type: string; path: string}>
+ ) => void,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ options?: {ignore?: Array}
+ ) => {
+ // Store callback with exact path as provided (for test matching)
+ // Also store normalized variants for flexibility
+ parcelWatcherCallbacks.set(rootPath, callback);
+ const normalizedPath = path.resolve(rootPath);
+ if (normalizedPath !== rootPath) {
+ parcelWatcherCallbacks.set(normalizedPath, callback);
+ }
+ if (!rootPath.startsWith('/')) {
+ parcelWatcherCallbacks.set(
+ path.join(process.cwd(), rootPath),
+ callback
+ );
+ }
+
+ const subscription = {
+ unsubscribe: vi.fn(async () => {
+ parcelWatcherCallbacks.delete(rootPath);
+ if (normalizedPath !== rootPath) {
+ parcelWatcherCallbacks.delete(normalizedPath);
+ }
+ if (!rootPath.startsWith('/')) {
+ parcelWatcherCallbacks.delete(path.join(process.cwd(), rootPath));
+ }
+ parcelWatcherSubscriptions.delete(rootPath);
+ })
+ };
+ parcelWatcherSubscriptions.set(rootPath, subscription);
+ return subscription;
+ }
+ )
+}));
+
vi.mock('fs', () => ({
default: {
watch: vi.fn(
@@ -2328,6 +3148,17 @@ vi.mock('fs', () => ({
}
}));
+function createENOENTError(filePath: string): NodeJS.ErrnoException {
+ const error = new Error(
+ `ENOENT: no such file or directory, open '${filePath}'`
+ ) as NodeJS.ErrnoException;
+ error.code = 'ENOENT';
+ error.errno = -2;
+ error.syscall = 'open';
+ error.path = filePath;
+ return error;
+}
+
vi.mock('fs/promises', () => ({
default: {
readFile: vi.fn(async (filePath: string) => {
@@ -2340,7 +3171,7 @@ vi.mock('fs/promises', () => ({
if (typeof content === 'string') {
return content;
}
- throw new Error('File not found: ' + filePath);
+ throw createENOENTError(filePath);
}),
readdir: vi.fn(async (dir: string, opts?: {withFileTypes?: boolean}) => {
const dirExists = checkDirectoryExists(filesystem, dir);
diff --git a/packages/next-intl/src/extractor/ExtractionCompiler.tsx b/packages/next-intl/src/extractor/ExtractionCompiler.tsx
index 714176bb4..54374beab 100644
--- a/packages/next-intl/src/extractor/ExtractionCompiler.tsx
+++ b/packages/next-intl/src/extractor/ExtractionCompiler.tsx
@@ -1,10 +1,9 @@
import CatalogManager from './catalog/CatalogManager.js';
+import MessageExtractor from './extractor/MessageExtractor.js';
import type {ExtractorConfig} from './types.js';
export default class ExtractionCompiler implements Disposable {
private manager: CatalogManager;
- private isDevelopment = false;
- private initialScanPromise: Promise | undefined;
constructor(
config: ExtractorConfig,
@@ -12,46 +11,38 @@ export default class ExtractionCompiler implements Disposable {
isDevelopment?: boolean;
projectRoot?: string;
sourceMap?: boolean;
+ extractor?: MessageExtractor;
} = {}
) {
- this.manager = new CatalogManager(config, opts);
- this.isDevelopment = opts.isDevelopment ?? false;
-
- // Kick off the initial scan as early as possible,
- // while awaiting it in `compile`. This also ensures
- // we're only scanning once.
- this.initialScanPromise = this.performInitialScan();
- }
-
- public async compile(resourcePath: string, source: string) {
- if (this.initialScanPromise) {
- await this.initialScanPromise;
- this.initialScanPromise = undefined;
- }
-
- const result = await this.manager.extractFileMessages(resourcePath, source);
-
- if (this.isDevelopment && result.changed) {
- // While we await the AST modification, we
- // don't need to await the persistence
- void this.manager.save();
- }
-
- return result;
+ const extractor = opts.extractor ?? new MessageExtractor(opts);
+ this.manager = new CatalogManager(config, {...opts, extractor});
+ this[Symbol.dispose] = this[Symbol.dispose].bind(this);
+ this.installExitHandlers();
}
- private async performInitialScan(): Promise {
+ public async extractAll() {
// We can't rely on all files being compiled (e.g. due to persistent
// caching), so loading the messages initially is necessary.
await this.manager.loadMessages();
await this.manager.save();
}
- public async extract() {
- await this.initialScanPromise;
- }
-
[Symbol.dispose](): void {
+ this.uninstallExitHandlers();
this.manager.destroy();
}
+
+ private installExitHandlers() {
+ const cleanup = this[Symbol.dispose];
+ process.on('exit', cleanup);
+ process.on('SIGINT', cleanup);
+ process.on('SIGTERM', cleanup);
+ }
+
+ private uninstallExitHandlers() {
+ const cleanup = this[Symbol.dispose];
+ process.off('exit', cleanup);
+ process.off('SIGINT', cleanup);
+ process.off('SIGTERM', cleanup);
+ }
}
diff --git a/packages/next-intl/src/extractor/catalog/CatalogLocales.tsx b/packages/next-intl/src/extractor/catalog/CatalogLocales.tsx
index b56ac1628..4d9ae2dd9 100644
--- a/packages/next-intl/src/extractor/catalog/CatalogLocales.tsx
+++ b/packages/next-intl/src/extractor/catalog/CatalogLocales.tsx
@@ -21,7 +21,6 @@ export default class CatalogLocales {
private sourceLocale: Locale;
private locales: MessagesConfig['locales'];
private watcher?: fs.FSWatcher;
- private cleanupHandlers: Array<() => void> = [];
private targetLocales?: Array;
private onChangeCallbacks: Set = new Set();
@@ -95,8 +94,6 @@ export default class CatalogLocales {
}
}
);
-
- this.setupCleanupHandlers();
}
private stopWatcher(): void {
@@ -104,11 +101,6 @@ export default class CatalogLocales {
this.watcher.close();
this.watcher = undefined;
}
-
- for (const handler of this.cleanupHandlers) {
- handler();
- }
- this.cleanupHandlers = [];
}
private async onChange(): Promise {
@@ -129,35 +121,4 @@ export default class CatalogLocales {
}
}
}
-
- private setupCleanupHandlers(): void {
- const cleanup = () => {
- if (this.watcher) {
- this.watcher.close();
- this.watcher = undefined;
- }
- };
-
- function exitHandler() {
- cleanup();
- }
- function sigintHandler() {
- cleanup();
- process.exit(0);
- }
- function sigtermHandler() {
- cleanup();
- process.exit(0);
- }
-
- process.once('exit', exitHandler);
- process.once('SIGINT', sigintHandler);
- process.once('SIGTERM', sigtermHandler);
-
- this.cleanupHandlers.push(() => {
- process.removeListener('exit', exitHandler);
- process.removeListener('SIGINT', sigintHandler);
- process.removeListener('SIGTERM', sigtermHandler);
- });
- }
}
diff --git a/packages/next-intl/src/extractor/catalog/CatalogManager.tsx b/packages/next-intl/src/extractor/catalog/CatalogManager.tsx
index c909d9ff8..e57314bf4 100644
--- a/packages/next-intl/src/extractor/catalog/CatalogManager.tsx
+++ b/packages/next-intl/src/extractor/catalog/CatalogManager.tsx
@@ -1,11 +1,12 @@
import fs from 'fs/promises';
import path from 'path';
-import MessageExtractor from '../extractor/MessageExtractor.js';
-import type Formatter from '../formatters/Formatter.js';
-import formatters from '../formatters/index.js';
+import type MessageExtractor from '../extractor/MessageExtractor.js';
+import type ExtractorCodec from '../format/ExtractorCodec.js';
+import {getFormatExtension, resolveCodec} from '../format/index.js';
import SourceFileScanner from '../source/SourceFileScanner.js';
-import type {ExtractedMessage, ExtractorConfig, Locale} from '../types.js';
-import {localeCompare} from '../utils.js';
+import SourceFileWatcher from '../source/SourceFileWatcher.js';
+import type {ExtractorConfig, ExtractorMessage, Locale} from '../types.js';
+import {getDefaultProjectRoot, localeCompare} from '../utils.js';
import CatalogLocales from './CatalogLocales.js';
import CatalogPersister from './CatalogPersister.js';
import SaveScheduler from './SaveScheduler.js';
@@ -13,15 +14,21 @@ import SaveScheduler from './SaveScheduler.js';
export default class CatalogManager {
private config: ExtractorConfig;
- /* The source of truth for which messages are used. */
+ /**
+ * The source of truth for which messages are used.
+ * NOTE: Should be mutated in place to keep `messagesById` and `messagesByFile` in sync.
+ */
private messagesByFile: Map<
/* File path */ string,
- Map* ID */ string, ExtractedMessage>
+ Map* ID */ string, ExtractorMessage>
> = new Map();
- /* Fast lookup for messages by ID across all files,
- * contains the same messages as `messagesByFile`. */
- private messagesById: Map = new Map();
+ /**
+ * Fast lookup for messages by ID across all files,
+ * contains the same messages as `messagesByFile`.
+ * NOTE: Should be mutated in place to keep `messagesById` and `messagesByFile` in sync.
+ */
+ private messagesById: Map = new Map();
/**
* This potentially also includes outdated ones that were initially available,
@@ -29,7 +36,7 @@ export default class CatalogManager {
**/
private translationsByTargetLocale: Map<
Locale,
- Map* ID */ string, ExtractedMessage>
+ Map* ID */ string, ExtractorMessage>
> = new Map();
private lastWriteByLocale: Map = new Map();
@@ -40,9 +47,10 @@ export default class CatalogManager {
// Cached instances
private persister?: CatalogPersister;
- private formatter?: Formatter;
+ private codec?: ExtractorCodec;
private catalogLocales?: CatalogLocales;
- private messageExtractor: MessageExtractor;
+ private extractor: MessageExtractor;
+ private sourceWatcher?: SourceFileWatcher;
// Resolves when all catalogs are loaded
// (but doesn't indicate that project scan is done)
@@ -54,44 +62,49 @@ export default class CatalogManager {
projectRoot?: string;
isDevelopment?: boolean;
sourceMap?: boolean;
- } = {}
+ extractor: MessageExtractor;
+ }
) {
this.config = config;
this.saveScheduler = new SaveScheduler(50);
- this.projectRoot = opts.projectRoot || process.cwd();
+ this.projectRoot = opts.projectRoot ?? getDefaultProjectRoot();
this.isDevelopment = opts.isDevelopment ?? false;
- this.messageExtractor = new MessageExtractor({
- isDevelopment: this.isDevelopment,
- projectRoot: this.projectRoot,
- sourceMap: opts.sourceMap
- });
+ this.extractor = opts.extractor;
+
+ if (this.isDevelopment) {
+ this.sourceWatcher = new SourceFileWatcher(
+ this.getSrcPaths(),
+ this.handleFileEvents.bind(this)
+ );
+ void this.sourceWatcher.start();
+ }
}
- private async getFormatter(): Promise {
- if (this.formatter) {
- return this.formatter;
- } else {
- const FormatterClass = (await formatters[this.config.messages.format]())
- .default;
- this.formatter = new FormatterClass();
- return this.formatter;
+ private async getCodec(): Promise {
+ if (!this.codec) {
+ this.codec = await resolveCodec(
+ this.config.messages.format,
+ this.projectRoot
+ );
}
+ return this.codec;
}
private async getPersister(): Promise {
if (this.persister) {
return this.persister;
} else {
- this.persister = new CatalogPersister(
- this.config.messages.path,
- await this.getFormatter()
- );
+ this.persister = new CatalogPersister({
+ messagesPath: this.config.messages.path,
+ codec: await this.getCodec(),
+ extension: getFormatExtension(this.config.messages.format)
+ });
return this.persister;
}
}
- private async getCatalogLocales(): Promise {
+ private getCatalogLocales(): CatalogLocales {
if (this.catalogLocales) {
return this.catalogLocales;
} else {
@@ -99,11 +112,10 @@ export default class CatalogManager {
this.projectRoot,
this.config.messages.path
);
- const formatter = await this.getFormatter();
this.catalogLocales = new CatalogLocales({
messagesDir,
sourceLocale: this.config.sourceLocale,
- extension: formatter.EXTENSION,
+ extension: getFormatExtension(this.config.messages.format),
locales: this.config.messages.locales
});
return this.catalogLocales;
@@ -111,8 +123,7 @@ export default class CatalogManager {
}
private async getTargetLocales(): Promise> {
- const catalogLocales = await this.getCatalogLocales();
- return catalogLocales.getTargetLocales();
+ return this.getCatalogLocales().getTargetLocales();
}
getSrcPaths(): Array {
@@ -124,66 +135,48 @@ export default class CatalogManager {
}
public async loadMessages() {
- this.loadCatalogsPromise = Promise.all([
- this.loadSourceMessages(),
- this.loadTargetMessages()
- ]);
-
- // Ensure catalogs are loaded before scanning source files.
- // Otherwise, `loadSourceMessages` might overwrite extracted
- // messages if it finishes after source file extraction.
+ const sourceDiskMessages = await this.loadSourceMessages();
+ this.loadCatalogsPromise = this.loadTargetMessages();
await this.loadCatalogsPromise;
- if (this.isDevelopment) {
- const catalogLocales = await this.getCatalogLocales();
- catalogLocales.subscribeLocalesChange(this.onLocalesChange);
- }
-
const sourceFiles = await SourceFileScanner.getSourceFiles(
this.getSrcPaths()
);
await Promise.all(
- sourceFiles.map(async (filePath) =>
- this.extractFileMessages(filePath, await fs.readFile(filePath, 'utf8'))
+ Array.from(sourceFiles).map(async (filePath) =>
+ this.processFile(filePath)
)
);
+
+ this.mergeSourceDiskMetadata(sourceDiskMessages);
+
+ if (this.isDevelopment) {
+ const catalogLocales = this.getCatalogLocales();
+ catalogLocales.subscribeLocalesChange(this.onLocalesChange);
+ }
}
- private async loadSourceMessages() {
- // First hydrate from source locale file to potentially init metadata
- const messages = await this.loadLocaleMessages(this.config.sourceLocale);
- const messagesById: typeof this.messagesById = new Map();
- const messagesByFile: typeof this.messagesByFile = new Map();
- for (const message of messages) {
- messagesById.set(message.id, message);
- if (message.references) {
- for (const ref of message.references) {
- const absoluteFilePath = path.join(this.projectRoot, ref.path);
- let fileMessages = messagesByFile.get(absoluteFilePath);
- if (!fileMessages) {
- fileMessages = new Map();
- messagesByFile.set(absoluteFilePath, fileMessages);
- }
- fileMessages.set(message.id, message);
- }
- }
+ private async loadSourceMessages(): Promise