diff --git a/examples/example-app-router-extracted/messages/de.po b/examples/example-app-router-extracted/messages/de.po
index 949cc1ecb..e919f9dcd 100644
--- a/examples/example-app-router-extracted/messages/de.po
+++ b/examples/example-app-router-extracted/messages/de.po
@@ -6,19 +6,19 @@ msgstr ""
"X-Generator: next-intl\n"
"X-Crowdin-SourceKey: msgstr\n"
-#: src/app/Counter.tsx
+#: src/app/Counter.tsx:16
msgid "jm1lmy"
msgstr "Anzahl: {count, number}"
-#: src/app/Counter.tsx
+#: src/app/Counter.tsx:17
msgid "tQLRmz"
msgstr "Erhöhen"
#. Default meta title if not overridden by pages
-#: src/app/layout.tsx
+#: src/app/layout.tsx:17
msgid "lNLCAE"
msgstr "next-intl Beispiel"
-#: src/app/page.tsx
+#: src/app/page.tsx:10
msgid "vlslj0"
msgstr "Hallo {name}!"
diff --git a/examples/example-app-router-extracted/messages/en.po b/examples/example-app-router-extracted/messages/en.po
index 12cca4b5b..3248143c9 100644
--- a/examples/example-app-router-extracted/messages/en.po
+++ b/examples/example-app-router-extracted/messages/en.po
@@ -6,19 +6,19 @@ msgstr ""
"X-Generator: next-intl\n"
"X-Crowdin-SourceKey: msgstr\n"
-#: src/app/Counter.tsx
+#: src/app/Counter.tsx:16
msgid "jm1lmy"
msgstr "Count: {count, number}"
-#: src/app/Counter.tsx
+#: src/app/Counter.tsx:17
msgid "tQLRmz"
msgstr "Increment"
#. Default meta title if not overridden by pages
-#: src/app/layout.tsx
+#: src/app/layout.tsx:17
msgid "lNLCAE"
msgstr "next-intl example"
-#: src/app/page.tsx
+#: src/app/page.tsx:10
msgid "vlslj0"
msgstr "Hey {name}!"
diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json
index 0e91c98a3..52a1ee5f5 100644
--- a/packages/next-intl/package.json
+++ b/packages/next-intl/package.json
@@ -129,7 +129,7 @@
"@swc/core": "^1.15.2",
"negotiator": "^1.0.0",
"next-intl-swc-plugin-extractor": "workspace:^",
- "po-parser": "^2.0.0",
+ "po-parser": "^2.1.1",
"use-intl": "workspace:^"
},
"peerDependencies": {
diff --git a/packages/next-intl/src/extractor/ExtractionCompiler.test.tsx b/packages/next-intl/src/extractor/ExtractionCompiler.test.tsx
index a446360f5..4373c2c77 100644
--- a/packages/next-intl/src/extractor/ExtractionCompiler.test.tsx
+++ b/packages/next-intl/src/extractor/ExtractionCompiler.test.tsx
@@ -561,8 +561,8 @@ describe('json format', () => {
[
"messages/de.json",
"{
- "7kKG3Q": "",
- "OpKKos": "Hallo!"
+ "OpKKos": "Hallo!",
+ "7kKG3Q": ""
}
",
],
@@ -610,16 +610,16 @@ describe('json format', () => {
[
"messages/en.json",
"{
- "NnE1NP": "Goodbye!",
- "OpKKos": "Hello!"
+ "OpKKos": "Hello!",
+ "NnE1NP": "Goodbye!"
}
",
],
[
"messages/de.json",
"{
- "NnE1NP": "",
- "OpKKos": "Hallo!"
+ "OpKKos": "Hallo!",
+ "NnE1NP": ""
}
",
],
@@ -701,13 +701,12 @@ describe('json format', () => {
await waitForWriteFileCalls(1);
- expect(JSON.parse(filesystem.project.messages!['en.json']))
- .toMatchInlineSnapshot(`
- {
- "7kKG3Q": "World!",
- "OpKKos": "Hello!",
- }
- `);
+ expect(filesystem.project.messages!['en.json']).toMatchInlineSnapshot(`
+ "{
+ "OpKKos": "Hello!",
+ "7kKG3Q": "World!"
+ }
+ "`);
filesystem.project.messages!['de.json'] = '{}';
simulateFileEvent('/project/messages', 'rename', 'de.json');
@@ -717,8 +716,8 @@ describe('json format', () => {
[
"messages/de.json",
"{
- "7kKG3Q": "",
- "OpKKos": ""
+ "OpKKos": "",
+ "7kKG3Q": ""
}
",
]
@@ -1042,9 +1041,57 @@ describe('po format', () => {
);
}
+ it('tracks all line numbers when same message appears multiple times in one file', async () => {
+ filesystem.project.src['Greeting.tsx'] =
+ `import {useExtracted} from 'next-intl';
+ function Greeting() {
+ const t = useExtracted();
+ return (
+
+ {t('Hello!')}
+ {t('Hey!')}
+ {t('Hello!')}
+
+ );
+ }
+ `;
+ filesystem.project.src['Greeting2.tsx'] =
+ `import {useExtracted} from 'next-intl';
+ function Greeting2() {
+ const t = useExtracted();
+ return t('Hello!');
+ }
+ `;
+ filesystem.project.messages = {};
+
+ using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(1);
+ expect(vi.mocked(fs.writeFile).mock.calls[0][1]).toMatchInlineSnapshot(`
+ "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:6
+ #: src/Greeting.tsx:8
+ #: src/Greeting2.tsx:4
+ msgid "OpKKos"
+ msgstr "Hello!"
+
+ #: src/Greeting.tsx:7
+ msgid "+YJVTi"
+ msgstr "Hey!"
+ "
+ `);
+ });
+
it('saves messages initially', async () => {
- filesystem.project.src['Greeting.tsx'] = `
- import {useExtracted} from 'next-intl';
+ filesystem.project.src['Greeting.tsx'] =
+ `import {useExtracted} from 'next-intl';
function Greeting() {
const t = useExtracted();
return {t('Hey!')}
;
@@ -1077,7 +1124,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:4
msgid "+YJVTi"
msgstr "Hey!"
",
@@ -1092,7 +1139,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:4
msgid "+YJVTi"
msgstr "Hallo!"
",
@@ -1153,7 +1200,7 @@ describe('po format', () => {
"X-Crowdin-SourceKey: msgstr\\n"
#. Shown on home screen
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "+YJVTi"
msgstr "Hey!"
",
@@ -1169,7 +1216,7 @@ describe('po format', () => {
"X-Crowdin-SourceKey: msgstr\\n"
#. Shown on home screen
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "+YJVTi"
msgstr "Hallo!"
",
@@ -1228,8 +1275,8 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Footer.tsx
- #: src/Greeting.tsx
+ #: src/Footer.tsx:5
+ #: src/Greeting.tsx:5
msgid "+YJVTi"
msgstr "Hey!"
",
@@ -1244,8 +1291,8 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Footer.tsx
- #: src/Greeting.tsx
+ #: src/Footer.tsx:5
+ #: src/Greeting.tsx:5
msgid "+YJVTi"
msgstr "Hallo!"
",
@@ -1254,6 +1301,51 @@ describe('po format', () => {
`);
});
+ it('merges descriptions when message appears in multiple files with different descriptions', async () => {
+ filesystem.project.src['FileY.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function FileY() {
+ const t = useExtracted();
+ return {t({message: 'Message', description: 'Description from FileY'})}
;
+ }
+ `;
+ filesystem.project.src['FileZ.tsx'] = `
+ import {useExtracted} from 'next-intl';
+ function FileZ() {
+ const t = useExtracted();
+ return (
+
+ {t('Message')}
+ {t({message: 'Message', description: 'Description from FileZ'})}
+
+ );
+ }
+ `;
+ filesystem.project.messages = {};
+
+ using compiler = createCompiler();
+ await compiler.extractAll();
+ await waitForWriteFileCalls(1);
+
+ expect(vi.mocked(fs.writeFile).mock.calls[0][1]).toMatchInlineSnapshot(`
+ "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"
+
+ #. Description from FileZ
+ #: src/FileY.tsx:5
+ #: src/FileZ.tsx:7
+ #: src/FileZ.tsx:8
+ msgid "T7Ry38"
+ msgstr "Message"
+ "
+ `);
+ });
+
it('updates references in all catalogs when message is reused in another file', async () => {
filesystem.project.src['Greeting.tsx'] = `
import {useExtracted} from 'next-intl';
@@ -1285,7 +1377,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "+YJVTi"
msgstr "Hey!"
",
@@ -1300,7 +1392,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "+YJVTi"
msgstr ""
",
@@ -1333,8 +1425,8 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Footer.tsx
- #: src/Greeting.tsx
+ #: src/Footer.tsx:5
+ #: src/Greeting.tsx:5
msgid "+YJVTi"
msgstr "Hey!"
",
@@ -1349,8 +1441,8 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Footer.tsx
- #: src/Greeting.tsx
+ #: src/Footer.tsx:5
+ #: src/Greeting.tsx:5
msgid "+YJVTi"
msgstr ""
",
@@ -1412,11 +1504,11 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Footer.tsx
+ #: src/Footer.tsx:5
msgid "+YJVTi"
msgstr "Hey!"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "4xqPlJ"
msgstr "Howdy!"
",
@@ -1431,11 +1523,11 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Footer.tsx
+ #: src/Footer.tsx:5
msgid "+YJVTi"
msgstr ""
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "4xqPlJ"
msgstr ""
",
@@ -1512,7 +1604,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/component-b.tsx
+ #: src/component-b.tsx:5
msgid "4xqPlJ"
msgstr "Howdy!"
",
@@ -1527,7 +1619,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/component-b.tsx
+ #: src/component-b.tsx:5
msgid "4xqPlJ"
msgstr ""
",
@@ -1641,7 +1733,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/component-b.tsx
+ #: src/component-b.tsx:5
msgid "OpKKos"
msgstr "Hello!"
",
@@ -1656,7 +1748,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/component-b.tsx
+ #: src/component-b.tsx:5
msgid "OpKKos"
msgstr "Hallo!"
",
@@ -1705,7 +1797,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/component-b.tsx
+ #: src/component-b.tsx:5
msgid "OpKKos"
msgstr "Hello!"
",
@@ -1720,7 +1812,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/component-b.tsx
+ #: src/component-b.tsx:5
msgid "OpKKos"
msgstr ""
",
@@ -1769,7 +1861,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/component-b.tsx
+ #: src/component-b.tsx:5
msgid "OpKKos"
msgstr "Hello!"
",
@@ -1784,7 +1876,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/component-b.tsx
+ #: src/component-b.tsx:5
msgid "OpKKos"
msgstr ""
",
@@ -1817,7 +1909,7 @@ describe('po format', () => {
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgctxt "ui"
msgid "OpKKos"
msgstr "Hello!"
@@ -1891,7 +1983,7 @@ msgstr "Hallo!"
"MIME-Version: 1.0\\n"
"X-Something-Else: test\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "OpKKos"
msgstr "Hello!"
",
@@ -1907,7 +1999,7 @@ msgstr "Hallo!"
"X-Crowdin-SourceKey: msgstr\\n"
"POT-Creation-Date: 2025-10-27 16:00+0000\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "OpKKos"
msgstr ""
",
@@ -1947,11 +2039,11 @@ msgstr "Hallo!"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/app/page.tsx
+ #: src/app/page.tsx:5
msgid "NhX4DJ"
msgstr "Hello"
- #: src/components/Header.tsx
+ #: src/components/Header.tsx:5
msgid "PwaN2o"
msgstr "Welcome"
",
@@ -1983,16 +2075,16 @@ msgstr "Hallo!"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/a.tsx
+ #: src/a.tsx:5
msgid "PmvAXH"
msgstr "Message A"
- #: src/b.tsx
- #: src/d.tsx
+ #: src/b.tsx:5
+ #: src/d.tsx:5
msgid "5bb321"
msgstr "Message B"
- #: src/c.tsx
+ #: src/c.tsx:5
msgid "c3UbA2"
msgstr "Message C"
",
@@ -2031,12 +2123,12 @@ msgstr "Hallo!"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
- msgid "7kKG3Q"
+ #: src/Greeting.tsx:5
+ msgid "OpKKos"
msgstr ""
- #: src/Greeting.tsx
- msgid "OpKKos"
+ #: src/Greeting.tsx:5
+ msgid "7kKG3Q"
msgstr ""
",
],
@@ -2083,7 +2175,7 @@ msgstr "Hallo!"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
#, fuzzy
msgid "+YJVTi"
msgstr "Hey!"
@@ -2099,7 +2191,7 @@ msgstr "Hallo!"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
#, c-format
msgid "+YJVTi"
msgstr "Hallo!"
@@ -2179,12 +2271,12 @@ msgstr "Hey!"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
#, c-format
msgid "+YJVTi"
msgstr "Hey!"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "jqdzk6"
msgstr "World"
",
@@ -2236,15 +2328,15 @@ msgstr "World"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "+YJVTi"
msgstr "Hey!"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "jqdzk6"
msgstr "World"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "ODGmph"
msgstr "!"
",
@@ -2301,20 +2393,20 @@ msgstr ""
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
#, no-wrap
msgid "+YJVTi"
msgstr "Hallo!"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "jqdzk6"
msgstr ""
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "ODGmph"
msgstr ""
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "pE58D7"
msgstr ""
",
@@ -2374,25 +2466,25 @@ msgstr ""
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "+YJVTi"
msgstr "Hallo!"
- #: src/Greeting.tsx
- msgid "I5NMJ8"
- msgstr ""
-
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "jqdzk6"
msgstr ""
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "ODGmph"
msgstr ""
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "pE58D7"
msgstr ""
+
+ #: src/Greeting.tsx:5
+ msgid "I5NMJ8"
+ msgstr ""
",
]
`);
@@ -2455,12 +2547,12 @@ msgstr "Hey!"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
#, fuzzy
msgid "+YJVTi"
msgstr "Hey!"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
msgid "7kKG3Q"
msgstr "World!"
",
@@ -2540,7 +2632,7 @@ msgstr "Hey!"
"X-Crowdin-SourceKey: msgstr\\n"
#. This is a description
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
#, c-format
msgid "OpKKos"
msgstr "Hello!"
@@ -2557,7 +2649,7 @@ msgstr "Hey!"
"X-Crowdin-SourceKey: msgstr\\n"
#. This is a description
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
#, fuzzy
msgid "OpKKos"
msgstr "Hallo!"
@@ -2573,15 +2665,15 @@ msgstr "Hey!"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
- msgid "nm/7yQ"
- msgstr "Hi!"
-
#. This is a description
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
#, c-format
msgid "OpKKos"
msgstr "Hello!"
+
+ #: src/Greeting.tsx:10
+ msgid "nm/7yQ"
+ msgstr "Hi!"
",
],
[
@@ -2594,15 +2686,15 @@ msgstr "Hey!"
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/Greeting.tsx
- msgid "nm/7yQ"
- msgstr ""
-
#. This is a description
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:5
#, fuzzy
msgid "OpKKos"
msgstr "Hallo!"
+
+ #: src/Greeting.tsx:10
+ msgid "nm/7yQ"
+ msgstr ""
",
],
]
@@ -2888,7 +2980,7 @@ msgstr "Hallo!"`
"X-Generator: next-intl\\n"
"X-Crowdin-SourceKey: msgstr\\n"
- #: src/new/Button.tsx
+ #: src/new/Button.tsx:5
msgid "cfI2fq"
msgstr "Click me updated"
",
@@ -3141,20 +3233,20 @@ describe('custom format', () => {
"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:5
+ msgctxt "OpKKos"
+ msgid "Hello!"
+ msgstr "Hello!"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:11
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!"
+ #: src/Greeting.tsx:12
+ msgctxt "misc.Fp6Fab"
+ msgid "Checking if you're logged in."
+ msgstr "Checking if you're logged in."
",
],
[
@@ -3166,20 +3258,20 @@ describe('custom format', () => {
"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:5
+ msgctxt "OpKKos"
+ msgid "Hello!"
+ msgstr "Hallo!"
- #: src/Greeting.tsx
+ #: src/Greeting.tsx:11
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!"
+ #: src/Greeting.tsx:12
+ msgctxt "misc.Fp6Fab"
+ msgid "Checking if you're logged in."
+ msgstr ""
",
],
]
diff --git a/packages/next-intl/src/extractor/catalog/CatalogManager.tsx b/packages/next-intl/src/extractor/catalog/CatalogManager.tsx
index ed9303042..db9e33b65 100644
--- a/packages/next-intl/src/extractor/catalog/CatalogManager.tsx
+++ b/packages/next-intl/src/extractor/catalog/CatalogManager.tsx
@@ -7,8 +7,13 @@ import SourceFileScanner from '../source/SourceFileScanner.js';
import SourceFileWatcher, {
type SourceFileWatcherEvent
} from '../source/SourceFileWatcher.js';
-import type {ExtractorConfig, ExtractorMessage, Locale} from '../types.js';
-import {getDefaultProjectRoot, localeCompare} from '../utils.js';
+import type {
+ ExtractorConfig,
+ ExtractorMessage,
+ ExtractorMessageReference,
+ Locale
+} from '../types.js';
+import {compareReferences, getDefaultProjectRoot} from '../utils.js';
import CatalogLocales from './CatalogLocales.js';
import CatalogPersister from './CatalogPersister.js';
import SaveScheduler from './SaveScheduler.js';
@@ -282,6 +287,7 @@ export default class CatalogManager implements Disposable {
}
const prevFileMessages = this.messagesByFile.get(absoluteFilePath);
+ const relativeFilePath = path.relative(this.projectRoot, absoluteFilePath);
// Init with all previous ones
const idsToRemove = Array.from(prevFileMessages?.keys() ?? []);
@@ -294,13 +300,15 @@ export default class CatalogManager implements Disposable {
// Merge with previous message if it exists
if (prevMessage) {
- const validated = prevMessage.references ?? [];
- message = {
- ...message,
- references: this.mergeReferences(validated, {
- path: path.relative(this.projectRoot, absoluteFilePath)
- })
- };
+ message = {...message};
+
+ if (message.references) {
+ message.references = this.mergeReferences(
+ prevMessage.references ?? [],
+ relativeFilePath,
+ message.references
+ );
+ }
// Merge other properties like description, or unknown
// attributes like flags that are opaque to us
@@ -319,8 +327,6 @@ export default class CatalogManager implements Disposable {
if (index !== -1) idsToRemove.splice(index, 1);
}
- const relativeFilePath = path.relative(this.projectRoot, absoluteFilePath);
-
// Clean up removed messages from `messagesById`
idsToRemove.forEach((id) => {
const message = this.messagesById.get(id);
@@ -357,17 +363,16 @@ export default class CatalogManager implements Disposable {
}
private mergeReferences(
- existing: Array<{path: string}>,
- current: {path: string}
- ): Array<{path: string}> {
- const dedup = new Map();
- for (const ref of existing) {
- dedup.set(ref.path, ref);
- }
- dedup.set(current.path, current);
- return Array.from(dedup.values()).sort((a, b) =>
- localeCompare(a.path, b.path)
+ existing: Array,
+ currentFilePath: string,
+ currentFileRefs: Array
+ ): Array {
+ // Keep refs from other files, replace all refs from the current file
+ const otherFileRefs = existing.filter(
+ (ref) => ref.path !== currentFilePath
);
+ const merged = [...otherFileRefs, ...currentFileRefs];
+ return merged.sort(compareReferences);
}
private haveMessagesChangedForFile(
diff --git a/packages/next-intl/src/extractor/extractor/MessageExtractor.test.tsx b/packages/next-intl/src/extractor/extractor/MessageExtractor.test.tsx
index 77f5904b0..95c9a01a8 100644
--- a/packages/next-intl/src/extractor/extractor/MessageExtractor.test.tsx
+++ b/packages/next-intl/src/extractor/extractor/MessageExtractor.test.tsx
@@ -31,6 +31,43 @@ it('can extract with source maps', async () => {
expect(result.map).not.toContain('');
});
+it('extracts same message used multiple times in one file with all references', async () => {
+ const result = await process(
+ `import {useExtracted} from 'next-intl';
+
+ function Component() {
+ const t = useExtracted();
+ return (
+
+ {t('Hello!')}
+ {t('Hello!')}
+
+ );
+ }
+ `
+ );
+
+ expect(result.messages).toMatchInlineSnapshot(`
+ [
+ {
+ "description": null,
+ "id": "OpKKos",
+ "message": "Hello!",
+ "references": [
+ {
+ "line": 7,
+ "path": "test.tsx",
+ },
+ {
+ "line": 8,
+ "path": "test.tsx",
+ },
+ ],
+ },
+ ]
+ `);
+});
+
it('does not add a fallback message in production', async () => {
const result = await process(
`import {useExtracted} from 'next-intl';
@@ -59,6 +96,7 @@ it('does not add a fallback message in production', async () => {
"message": "Hey!",
"references": [
{
+ "line": 5,
"path": "test.tsx",
},
],
diff --git a/packages/next-intl/src/extractor/types.tsx b/packages/next-intl/src/extractor/types.tsx
index 97c15f7fe..1cf213f0a 100644
--- a/packages/next-intl/src/extractor/types.tsx
+++ b/packages/next-intl/src/extractor/types.tsx
@@ -5,11 +5,16 @@ import type {MessagesFormat} from './format/types.js';
// don't require a match here.
export type Locale = string;
+export type ExtractorMessageReference = {
+ path: string;
+ line?: number;
+};
+
export type ExtractorMessage = {
id: string;
message: string;
description?: string;
- references?: Array<{path: string}>;
+ references?: Array;
/** Allows for additional properties like .po flags to be read and later written. */
[key: string]: unknown;
};
diff --git a/packages/next-intl/src/extractor/utils.test.tsx b/packages/next-intl/src/extractor/utils.test.tsx
index a7dded9db..62e8be862 100644
--- a/packages/next-intl/src/extractor/utils.test.tsx
+++ b/packages/next-intl/src/extractor/utils.test.tsx
@@ -8,36 +8,58 @@ describe('getSortedMessages', () => {
{
id: 'a',
message: 'a',
- references: [{path: 'components/B.tsx'}]
+ references: [{path: 'components/B.tsx', line: 1}]
},
{
id: 'b',
message: 'b',
- references: [{path: 'components/A.tsx'}]
+ references: [{path: 'components/A.tsx', line: 1}]
}
]).map((message) => message.id)
).toEqual(['b', 'a']);
});
- it('uses message ids to break ties when reference paths match', () => {
+ it('sorts by line number when reference paths match', () => {
expect(
getSortedMessages([
{
- id: 'c',
+ id: 'b',
message: 'b',
- references: [{path: 'components/B.tsx'}]
+ references: [{path: 'components/A.tsx', line: 20}]
},
{
- id: 'b',
+ id: 'a',
message: 'a',
- references: [{path: 'components/A.tsx'}]
+ references: [{path: 'components/A.tsx', line: 10}]
},
{
- id: 'a',
+ id: 'c',
message: 'c',
- references: [{path: 'components/A.tsx'}]
+ references: [{path: 'components/A.tsx', line: 30}]
}
]).map((message) => message.id)
).toEqual(['a', 'b', 'c']);
});
+
+ it('preserves original order when reference paths and lines match', () => {
+ expect(
+ getSortedMessages([
+ {
+ id: 'c',
+ message: 'c',
+ references: [{path: 'components/A.tsx', line: 10}]
+ },
+ {
+ id: 'a',
+ message: 'a',
+ references: [{path: 'components/A.tsx', line: 10}]
+ },
+ {
+ id: 'b',
+ message: 'b',
+ references: [{path: 'components/A.tsx', line: 10}]
+ }
+ ]).map((message) => message.id)
+ ).toEqual(['c', 'a', 'b']);
+ });
});
diff --git a/packages/next-intl/src/extractor/utils.tsx b/packages/next-intl/src/extractor/utils.tsx
index 97bcfb1b5..39611f317 100644
--- a/packages/next-intl/src/extractor/utils.tsx
+++ b/packages/next-intl/src/extractor/utils.tsx
@@ -1,4 +1,4 @@
-import type {ExtractorMessage} from './types.js';
+import type {ExtractorMessage, ExtractorMessageReference} from './types.js';
// Essentialls lodash/set, but we avoid this dependency
export function setNestedProperty(
@@ -28,14 +28,14 @@ export function getSortedMessages(
messages: Array
): Array {
return messages.toSorted((messageA, messageB) => {
- const pathA = messageA.references?.[0]?.path ?? '';
- const pathB = messageB.references?.[0]?.path ?? '';
+ const refA = messageA.references?.[0];
+ const refB = messageB.references?.[0];
- if (pathA === pathB) {
- return localeCompare(messageA.id, messageB.id);
- } else {
- return localeCompare(pathA, pathB);
- }
+ // No references: preserve original (extraction) order
+ if (!refA || !refB) return 0;
+
+ // Sort by path, then line. Same path+line: preserve original order
+ return compareReferences(refA, refB);
});
}
@@ -43,6 +43,15 @@ export function localeCompare(a: string, b: string) {
return a.localeCompare(b, 'en');
}
+export function compareReferences(
+ refA: ExtractorMessageReference,
+ refB: ExtractorMessageReference
+): number {
+ const pathCompare = localeCompare(refA.path, refB.path);
+ if (pathCompare !== 0) return pathCompare;
+ return (refA.line ?? 0) - (refB.line ?? 0);
+}
+
export function getDefaultProjectRoot() {
return process.cwd();
}
diff --git a/packages/swc-plugin-extractor/Cargo.toml b/packages/swc-plugin-extractor/Cargo.toml
index fdbe723ce..d428a54fc 100644
--- a/packages/swc-plugin-extractor/Cargo.toml
+++ b/packages/swc-plugin-extractor/Cargo.toml
@@ -15,6 +15,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
base64 = "0.22"
+indexmap = "2.7"
rustc-hash = "2.1.0"
serde = "1.0.203"
serde_json = "1.0.117"
diff --git a/packages/swc-plugin-extractor/src/lib.rs b/packages/swc-plugin-extractor/src/lib.rs
index 271ab506a..8cdab47a1 100644
--- a/packages/swc-plugin-extractor/src/lib.rs
+++ b/packages/swc-plugin-extractor/src/lib.rs
@@ -3,12 +3,14 @@
mod key_generator;
+use indexmap::IndexMap;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use swc_atoms::Wtf8Atom;
use swc_common::{errors::HANDLER, Spanned, DUMMY_SP};
use swc_core::{
- plugin::proxies::TransformPluginProgramMetadata, transform_common::output::experimental_emit,
+ common::SourceMapper, plugin::proxies::TransformPluginProgramMetadata,
+ transform_common::output::experimental_emit,
};
use swc_ecma_ast::*;
use swc_ecma_utils::ExprFactory;
@@ -24,12 +26,16 @@ fn next_intl_plugin(mut program: Program, data: TransformPluginProgramMetadata)
)
.expect("Invalid config");
- let mut visitor = TransformVisitor::new(config.is_development, config.file_path);
+ let mut visitor = TransformVisitor::new(
+ config.is_development,
+ config.file_path,
+ Some(Box::new(data.source_map) as Box),
+ );
program.visit_mut_with(&mut visitor);
experimental_emit(
"results".into(),
- serde_json::to_string(&visitor.results).unwrap(),
+ serde_json::to_string(&visitor.get_results()).unwrap(),
);
program
@@ -47,25 +53,36 @@ struct Config {
pub struct TransformVisitor {
is_development: bool,
file_path: String,
+ source_map: Option>,
hook_local_names: FxHashMap,
translator_map: FxHashMap,
- results: Vec,
+ /// Messages keyed by ID to aggregate duplicate usages (IndexMap preserves insertion order)
+ results_by_id: IndexMap,
}
impl TransformVisitor {
- pub fn new(is_development: bool, file_path: String) -> Self {
+ pub fn new(
+ is_development: bool,
+ file_path: String,
+ source_map: Option>,
+ ) -> Self {
Self {
is_development,
file_path,
+ source_map,
hook_local_names: Default::default(),
translator_map: Default::default(),
- results: Default::default(),
+ results_by_id: Default::default(),
}
}
+ pub fn get_results(&self) -> Vec {
+ self.results_by_id.values().cloned().collect()
+ }
+
fn define_translator(&mut self, name: Id, namespace: Option) {
self.translator_map
.insert(name, TranslatorInfo { namespace });
@@ -78,16 +95,17 @@ struct TranslatorInfo {
}
#[derive(Debug, Clone, Serialize)]
-struct StrictExtractedMessage {
- id: Wtf8Atom,
- message: Wtf8Atom,
- description: Option,
- references: Vec,
+pub struct StrictExtractedMessage {
+ pub id: Wtf8Atom,
+ pub message: Wtf8Atom,
+ pub description: Option,
+ pub references: Vec,
}
#[derive(Debug, Clone, Serialize)]
-struct Reference {
- path: String,
+pub struct Reference {
+ pub path: String,
+ pub line: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -222,18 +240,30 @@ impl VisitMut for TransformVisitor {
.join(NAMESPACE_SEPARATOR)
.into()
});
- let mut message = StrictExtractedMessage {
- id: full_key,
- message: message_text.clone(),
- description: None,
- references: vec![Reference {
- path: self.file_path.clone(),
- }],
+ let line = self
+ .source_map
+ .as_ref()
+ .map_or(0, |sm| sm.lookup_char_pos(call.span.lo).line);
+ let new_reference = Reference {
+ path: self.file_path.clone(),
+ line,
};
- if let Some(description) = description {
- message.description = Some(description.clone());
+
+ // Aggregate duplicate messages by ID
+ if let Some(existing) = self.results_by_id.get_mut(&full_key) {
+ existing.references.push(new_reference);
+ if existing.description.is_none() {
+ existing.description = description;
+ }
+ } else {
+ let message = StrictExtractedMessage {
+ id: full_key.clone(),
+ message: message_text.clone(),
+ description,
+ references: vec![new_reference],
+ };
+ self.results_by_id.insert(full_key, message);
}
- self.results.push(message);
// Transform the argument based on type
match &mut *call.args[0].expr {
diff --git a/packages/swc-plugin-extractor/target/wasm32-wasip1/release/swc_plugin_extractor.wasm b/packages/swc-plugin-extractor/target/wasm32-wasip1/release/swc_plugin_extractor.wasm
index e5a6ddc13..37862fe86 100755
Binary files a/packages/swc-plugin-extractor/target/wasm32-wasip1/release/swc_plugin_extractor.wasm and b/packages/swc-plugin-extractor/target/wasm32-wasip1/release/swc_plugin_extractor.wasm differ
diff --git a/packages/swc-plugin-extractor/tests/fixture.rs b/packages/swc-plugin-extractor/tests/fixture.rs
index f79e6d331..0c5e2dbaa 100644
--- a/packages/swc-plugin-extractor/tests/fixture.rs
+++ b/packages/swc-plugin-extractor/tests/fixture.rs
@@ -1,38 +1,74 @@
+use std::cell::RefCell;
+use std::fs;
use std::path::PathBuf;
+use std::rc::Rc;
-use swc_common::Mark;
+use serde_json::Value;
+use swc_common::{FileName, Globals, Mark, SourceMap, GLOBALS};
use swc_core::ecma::{
- parser::{EsSyntax, Syntax},
+ parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax},
transforms::{
base::resolver,
testing::{test_fixture, FixtureTestConfig},
},
};
-use swc_ecma_ast::Pass;
-use swc_ecma_visit::visit_mut_pass;
+use swc_ecma_ast::{EsVersion, Pass};
+use swc_ecma_visit::VisitMutWith;
use swc_plugin_extractor::TransformVisitor;
-fn tr() -> impl Pass {
+struct VisitorPass {
+ visitor: Rc>,
+}
+
+impl Pass for VisitorPass {
+ fn process(&mut self, program: &mut swc_ecma_ast::Program) {
+ program.visit_mut_with(&mut *self.visitor.borrow_mut());
+ }
+}
+
+fn tr(visitor_rc: Rc>) -> impl Pass {
let unresolved_mark = Mark::new();
let top_level_mark = Mark::new();
(
resolver(unresolved_mark, top_level_mark, false),
- visit_mut_pass(TransformVisitor::new(true, "input.js".to_string())),
+ VisitorPass {
+ visitor: visitor_rc,
+ },
)
}
+fn parse(cm: &SourceMap, code: &str) -> swc_ecma_ast::Program {
+ let fm = cm.new_source_file(FileName::Anon.into(), code.to_string());
+ let lexer = Lexer::new(
+ Syntax::Es(EsSyntax {
+ jsx: true,
+ ..Default::default()
+ }),
+ EsVersion::EsNext,
+ StringInput::from(&*fm),
+ None,
+ );
+ let mut parser = Parser::new_from(lexer);
+ parser.parse_program().unwrap()
+}
+
#[testing::fixture("tests/fixture/**/input.js")]
fn test(input: PathBuf) {
let dir = input.parent().unwrap().to_path_buf();
let output = dir.join("output.js");
+ let output_json = dir.join("output.json");
+
+ let visitor = TransformVisitor::new(true, "input.js".to_string(), None);
+ let visitor_rc = Rc::new(RefCell::new(visitor));
+ // Test JS transformation
test_fixture(
Syntax::Es(EsSyntax {
jsx: true,
..Default::default()
}),
- &|_| tr(),
+ &|_| tr(visitor_rc.clone()),
&input,
&output,
FixtureTestConfig {
@@ -41,4 +77,48 @@ fn test(input: PathBuf) {
..Default::default()
},
);
+
+ // Test JSON output - run transformation again with SourceMap for accurate line numbers
+ let globals = Globals::new();
+ GLOBALS.set(&globals, || {
+ let code = fs::read_to_string(&input).unwrap();
+ let cm = SourceMap::default();
+ let mut program = parse(&cm, &code);
+
+ if !program.is_module() {
+ panic!("Parsed as script, expected module");
+ }
+
+ let unresolved_mark = Mark::new();
+ let top_level_mark = Mark::new();
+
+ program.visit_mut_with(&mut resolver(unresolved_mark, top_level_mark, false));
+
+ let file_name = input.file_name().unwrap().to_string_lossy().to_string();
+ // Use the same SourceMap that was used for parsing so spans match
+ let mut visitor = TransformVisitor::new(
+ true,
+ file_name,
+ Some(Box::new(cm) as Box),
+ );
+
+ program.visit_mut_with(&mut visitor);
+
+ // Use results directly from visitor - it calculates line numbers correctly with SourceMap
+ let actual_results = visitor.get_results();
+ let actual_json: Value = serde_json::to_value(&actual_results).unwrap();
+
+ let expected_json_str = fs::read_to_string(&output_json)
+ .unwrap_or_else(|_| panic!("Expected output.json not found at {:?}", output_json));
+ let expected_json: Value = serde_json::from_str(&expected_json_str)
+ .unwrap_or_else(|_| panic!("Failed to parse expected JSON at {:?}", output_json));
+
+ if actual_json != expected_json {
+ panic!(
+ "JSON output mismatch.\nExpected:\n{}\nActual:\n{}",
+ serde_json::to_string_pretty(&expected_json).unwrap(),
+ serde_json::to_string_pretty(&actual_json).unwrap()
+ );
+ }
+ });
}
diff --git a/packages/swc-plugin-extractor/tests/fixture/alias-hook/output.json b/packages/swc-plugin-extractor/tests/fixture/alias-hook/output.json
new file mode 100644
index 000000000..fdc6772a7
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/alias-hook/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "+YJVTi",
+ "message": "Hey!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/async-basic/output.json b/packages/swc-plugin-extractor/tests/fixture/async-basic/output.json
new file mode 100644
index 000000000..782c01954
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/async-basic/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "0KGiQf",
+ "message": "Hello there!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/async-explicit-id/output.json b/packages/swc-plugin-extractor/tests/fixture/async-explicit-id/output.json
new file mode 100644
index 000000000..67325d5ea
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/async-explicit-id/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "greeting",
+ "message": "Hello {name}!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/async-locale/output.json b/packages/swc-plugin-extractor/tests/fixture/async-locale/output.json
new file mode 100644
index 000000000..782c01954
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/async-locale/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "0KGiQf",
+ "message": "Hello there!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/async-namespace/output.json b/packages/swc-plugin-extractor/tests/fixture/async-namespace/output.json
new file mode 100644
index 000000000..63b06293a
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/async-namespace/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "ui.OpKKos",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/async-rename/output.json b/packages/swc-plugin-extractor/tests/fixture/async-rename/output.json
new file mode 100644
index 000000000..782c01954
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/async-rename/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "0KGiQf",
+ "message": "Hello there!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/basic/output.json b/packages/swc-plugin-extractor/tests/fixture/basic/output.json
new file mode 100644
index 000000000..fdc6772a7
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/basic/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "+YJVTi",
+ "message": "Hey!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/date-format/output.json b/packages/swc-plugin-extractor/tests/fixture/date-format/output.json
new file mode 100644
index 000000000..9996f2378
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/date-format/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "5n+ZPU",
+ "message": "{date, date, short}!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/duplicates/input.js b/packages/swc-plugin-extractor/tests/fixture/duplicates/input.js
new file mode 100644
index 000000000..ab9afe8ab
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/duplicates/input.js
@@ -0,0 +1,11 @@
+import { useExtracted } from "next-intl";
+
+function Component() {
+ const t = useExtracted();
+ t("Hello!");
+
+ // Some other code
+ console.log("test");
+
+ t("Hello!");
+}
diff --git a/packages/swc-plugin-extractor/tests/fixture/duplicates/output.js b/packages/swc-plugin-extractor/tests/fixture/duplicates/output.js
new file mode 100644
index 000000000..f43723b0d
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/duplicates/output.js
@@ -0,0 +1,8 @@
+import { useTranslations as useTranslations$1 } from "next-intl";
+function Component() {
+ const t = useTranslations$1();
+ t("OpKKos", void 0, void 0, "Hello!");
+ // Some other code
+ console.log("test");
+ t("OpKKos", void 0, void 0, "Hello!");
+}
diff --git a/packages/swc-plugin-extractor/tests/fixture/duplicates/output.json b/packages/swc-plugin-extractor/tests/fixture/duplicates/output.json
new file mode 100644
index 000000000..f4db4ee58
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/duplicates/output.json
@@ -0,0 +1,17 @@
+[
+ {
+ "id": "OpKKos",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ },
+ {
+ "path": "input.js",
+ "line": 10
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/duplicates/output.map b/packages/swc-plugin-extractor/tests/fixture/duplicates/output.map
new file mode 100644
index 000000000..a1754186c
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/duplicates/output.map
@@ -0,0 +1 @@
+{"version":3,"sources":["input.js"],"sourcesContent":["import { useExtracted } from \"next-intl\";\n\nfunction Component() {\n const t = useExtracted();\n t(\"Hello!\");\n\n // Some other code\n console.log(\"test\");\n\n t(\"Hello!\");\n}\n"],"names":[],"mappings":"AAAA,SAAS,oCAAY,QAAQ,YAAY;AAEzC,SAAS;IACP,MAAM,IAAI;IACV,EAAE;IAEF,kBAAkB;IAClB,QAAQ,GAAG,CAAC;IAEZ,EAAE;AACJ"}
diff --git a/packages/swc-plugin-extractor/tests/fixture/existing-aliased-hook/output.json b/packages/swc-plugin-extractor/tests/fixture/existing-aliased-hook/output.json
new file mode 100644
index 000000000..328c98027
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/existing-aliased-hook/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "piskIR",
+ "message": "Hello from extracted!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 6
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/existing-hook/output.json b/packages/swc-plugin-extractor/tests/fixture/existing-hook/output.json
new file mode 100644
index 000000000..328c98027
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/existing-hook/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "piskIR",
+ "message": "Hello from extracted!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 6
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/let/output.json b/packages/swc-plugin-extractor/tests/fixture/let/output.json
new file mode 100644
index 000000000..fdc6772a7
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/let/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "+YJVTi",
+ "message": "Hey!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/multiple-hooks/output.json b/packages/swc-plugin-extractor/tests/fixture/multiple-hooks/output.json
new file mode 100644
index 000000000..73e1ac353
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/multiple-hooks/output.json
@@ -0,0 +1,90 @@
+[
+ {
+ "id": "tnuBMt",
+ "message": "Page title",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 7
+ }
+ ]
+ },
+ {
+ "id": "OpKKos",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 13
+ }
+ ]
+ },
+ {
+ "id": "mOPTEA",
+ "message": "Server data message",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 18
+ }
+ ]
+ },
+ {
+ "id": "MgvtBu",
+ "message": "Component message",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 23
+ }
+ ]
+ },
+ {
+ "id": "sJK5Uk",
+ "message": "Another one 1",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 28
+ }
+ ]
+ },
+ {
+ "id": "2k7cS1",
+ "message": "Another one 2",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 33
+ }
+ ]
+ },
+ {
+ "id": "another.6jb0KP",
+ "message": "Two 1",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 38
+ }
+ ]
+ },
+ {
+ "id": "another.KVQtmd",
+ "message": "Two 2",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 43
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/namespace/output.json b/packages/swc-plugin-extractor/tests/fixture/namespace/output.json
new file mode 100644
index 000000000..63b06293a
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/namespace/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "ui.OpKKos",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-double-quotes/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-double-quotes/output.json
new file mode 100644
index 000000000..135654a5d
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-double-quotes/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "greeting",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-formats/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-formats/output.json
new file mode 100644
index 000000000..135654a5d
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-formats/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "greeting",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-namespace/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-namespace/output.json
new file mode 100644
index 000000000..4bbaf5c03
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-namespace/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "ui.greeting",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-rich/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-rich/output.json
new file mode 100644
index 000000000..227b1ecf3
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-rich/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "greeting",
+ "message": "Hello Alice!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-single-quotes/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-single-quotes/output.json
new file mode 100644
index 000000000..135654a5d
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-single-quotes/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "greeting",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-template-quotes/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-template-quotes/output.json
new file mode 100644
index 000000000..135654a5d
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-template-quotes/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "greeting",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/obj-id-values/output.json b/packages/swc-plugin-extractor/tests/fixture/obj-id-values/output.json
new file mode 100644
index 000000000..135654a5d
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/obj-id-values/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "greeting",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/quote-variations/output.json b/packages/swc-plugin-extractor/tests/fixture/quote-variations/output.json
new file mode 100644
index 000000000..1e0cd0a87
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/quote-variations/output.json
@@ -0,0 +1,35 @@
+[
+ {
+ "id": "OpKKos",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ },
+ {
+ "id": "+YJVTi",
+ "message": "Hey!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 6
+ }
+ ]
+ },
+ {
+ "id": "nm/7yQ",
+ "message": "Hi!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 7
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/rename/output.json b/packages/swc-plugin-extractor/tests/fixture/rename/output.json
new file mode 100644
index 000000000..cb42b23dc
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/rename/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "OpKKos",
+ "message": "Hello!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/shadow/output.json b/packages/swc-plugin-extractor/tests/fixture/shadow/output.json
new file mode 100644
index 000000000..fdc6772a7
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/shadow/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "+YJVTi",
+ "message": "Hey!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/t-has/input.js b/packages/swc-plugin-extractor/tests/fixture/t-has/input.js
index edcacf4f9..a2b99cf35 100644
--- a/packages/swc-plugin-extractor/tests/fixture/t-has/input.js
+++ b/packages/swc-plugin-extractor/tests/fixture/t-has/input.js
@@ -2,5 +2,9 @@ import {useExtracted} from 'next-intl';
function Component() {
const t = useExtracted();
- t.has('Hello there!');
+ if (t.has('Hello here!')) {
+ return t('Hello here!');
+ } else {
+ return t('Hello there!');
+ }
}
diff --git a/packages/swc-plugin-extractor/tests/fixture/t-has/output.js b/packages/swc-plugin-extractor/tests/fixture/t-has/output.js
index 22dcd14c9..921157914 100644
--- a/packages/swc-plugin-extractor/tests/fixture/t-has/output.js
+++ b/packages/swc-plugin-extractor/tests/fixture/t-has/output.js
@@ -1,5 +1,9 @@
import { useTranslations as useTranslations$1 } from 'next-intl';
function Component() {
const t = useTranslations$1();
- t.has("0KGiQf");
+ if (t.has("j0tI96")) {
+ return t("j0tI96", void 0, void 0, "Hello here!");
+ } else {
+ return t("0KGiQf", void 0, void 0, "Hello there!");
+ }
}
diff --git a/packages/swc-plugin-extractor/tests/fixture/t-has/output.json b/packages/swc-plugin-extractor/tests/fixture/t-has/output.json
new file mode 100644
index 000000000..b135522d6
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/t-has/output.json
@@ -0,0 +1,28 @@
+[
+ {
+ "description": null,
+ "id": "j0tI96",
+ "message": "Hello here!",
+ "references": [
+ {
+ "line": 5,
+ "path": "input.js"
+ },
+ {
+ "line": 6,
+ "path": "input.js"
+ }
+ ]
+ },
+ {
+ "description": null,
+ "id": "0KGiQf",
+ "message": "Hello there!",
+ "references": [
+ {
+ "line": 8,
+ "path": "input.js"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/packages/swc-plugin-extractor/tests/fixture/t-has/output.map b/packages/swc-plugin-extractor/tests/fixture/t-has/output.map
index 458f77738..ebae9e09c 100644
--- a/packages/swc-plugin-extractor/tests/fixture/t-has/output.map
+++ b/packages/swc-plugin-extractor/tests/fixture/t-has/output.map
@@ -1 +1 @@
-{"version":3,"sources":["input.js"],"sourcesContent":["import {useExtracted} from 'next-intl';\n\nfunction Component() {\n const t = useExtracted();\n t.has('Hello there!');\n}\n"],"names":[],"mappings":"AAAA,SAAQ,oCAAY,QAAO,YAAY;AAEvC,SAAS;IACP,MAAM,IAAI;IACV,EAAE,GAAG,CAAC;AACR"}
+{"version":3,"sources":["input.js"],"sourcesContent":["import {useExtracted} from 'next-intl';\n\nfunction Component() {\n const t = useExtracted();\n if (t.has('Hello here!')) {\n return t('Hello here!');\n } else {\n return t('Hello there!');\n }\n}\n"],"names":[],"mappings":"AAAA,SAAQ,oCAAY,QAAO,YAAY;AAEvC,SAAS;IACP,MAAM,IAAI;IACV,IAAI,EAAE,GAAG,CAAC,WAAgB;QACxB,OAAO,EAAE;IACX,OAAO;QACL,OAAO,EAAE;IACX;AACF"}
diff --git a/packages/swc-plugin-extractor/tests/fixture/t-markup/output.json b/packages/swc-plugin-extractor/tests/fixture/t-markup/output.json
new file mode 100644
index 000000000..a2dc843be
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/t-markup/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "C+nN8a",
+ "message": "Hello Alice!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/t-rich/output.json b/packages/swc-plugin-extractor/tests/fixture/t-rich/output.json
new file mode 100644
index 000000000..a2dc843be
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/t-rich/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "C+nN8a",
+ "message": "Hello Alice!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/packages/swc-plugin-extractor/tests/fixture/values/output.json b/packages/swc-plugin-extractor/tests/fixture/values/output.json
new file mode 100644
index 000000000..1b7f69846
--- /dev/null
+++ b/packages/swc-plugin-extractor/tests/fixture/values/output.json
@@ -0,0 +1,13 @@
+[
+ {
+ "id": "tBFOH1",
+ "message": "Hello, {name}!",
+ "description": null,
+ "references": [
+ {
+ "path": "input.js",
+ "line": 5
+ }
+ ]
+ }
+]
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d16081696..ff24c781a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -830,8 +830,8 @@ importers:
specifier: workspace:^
version: link:../swc-plugin-extractor
po-parser:
- specifier: ^2.0.0
- version: 2.0.0
+ specifier: ^2.1.1
+ version: 2.1.1
use-intl:
specifier: workspace:^
version: link:../use-intl
@@ -12235,8 +12235,8 @@ packages:
resolution: {integrity: sha512-2Rb3vm+EXble/sMXNSu6eoBx8e79gKqhNq9F5ZWW6ERNCTE/Q0wQNne5541tE5vKjfM8hpNCYL+LGc1YTfI0dg==}
engines: {node: '>=6'}
- po-parser@2.0.0:
- resolution: {integrity: sha512-SZvoKi3PoI/hHa2V9je9CW7Xgxl4dvO74cvaa6tWShIHT51FkPxje6pt0gTJznJrU67ix91nDaQp2hUxkOYhKA==}
+ po-parser@2.1.1:
+ resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -15333,6 +15333,7 @@ packages:
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-fetch@3.6.2:
resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==}
@@ -30938,7 +30939,7 @@ snapshots:
transitivePeerDependencies:
- typescript
- po-parser@2.0.0: {}
+ po-parser@2.1.1: {}
points-on-curve@0.2.0: {}