next-intl-codemod 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 seob717
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,92 @@
1
- # Temporary Holding Version
1
+ # next-intl-codemod
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Migrates a Next.js app from `useTranslations` / `getTranslations` with hand-written keys to
4
+ [`useExtracted` / `getExtracted`](https://next-intl.dev/docs/usage/extraction) with inline messages,
5
+ and carries the existing translations over so nothing has to be translated again.
6
+
7
+ ```sh
8
+ npx next-intl-codemod --messages 'messages/{locale}.json' --source-locale en
9
+ ```
10
+
11
+ ## What it does
12
+
13
+ For every `t` that is bound to one of the old hooks and only ever called with static keys:
14
+
15
+ ```tsx
16
+ // before
17
+ import {useTranslations} from 'next-intl';
18
+ const t = useTranslations('Checkout');
19
+ t('title');
20
+ t.rich('terms', {link: (chunks) => <a>{chunks}</a>});
21
+
22
+ // after
23
+ import {useExtracted} from 'next-intl';
24
+ const t = useExtracted();
25
+ t('Checkout');
26
+ t.rich('Please accept the <link>terms</link>.', {link: (chunks) => <a>{chunks}</a>});
27
+ ```
28
+
29
+ 1. The key is replaced by the source-locale text from your catalogs. Ids are generated exactly like
30
+ the extractor does (SHA-512, base64url, 6 chars), so a later `next dev` / `next build` leaves the
31
+ catalogs untouched.
32
+ 2. Catalogs for all locales are written in the extraction format (`po` or `json`) with the old
33
+ translations filled in. An existing extraction catalog is merged, never overwritten. A `.po`
34
+ catalog in the previous next-intl layout (keys in `msgid`) stops the run before anything changes.
35
+ 3. If one source text was translated differently under different keys, each distinct translation set
36
+ keeps its own message with an explicit id (the old key), so no translation is lost:
37
+ ```tsx
38
+ t({id: 'pricing.enterprise.seats', message: 'Unlimited'});
39
+ ```
40
+ 4. Everything the extractor cannot handle is left untouched and listed in the report: dynamic keys
41
+ (`t(key)`, `t(\`a.${b}\`)`), dynamic namespaces, `t.raw`, `t.has`, `t` passed to another function
42
+ or returned, keys missing from the catalogs. The old hook import stays as long as one binding in the
43
+ file still needs it.
44
+
45
+ ## Options
46
+
47
+ | Option | Default | |
48
+ | --- | --- | --- |
49
+ | `--messages <pattern>` | required | Old catalogs. `{locale}` is required, `*` matches several files per locale that are merged: `messages/{locale}.json`, `locales/{locale}/*.json` |
50
+ | `--source-locale <locale>` | required | Locale whose texts become the inline messages |
51
+ | `--src <dir>` | `src` | Source directory, repeatable |
52
+ | `--out <dir>` | `messages` | Where the extraction catalogs are written (`<locale>.po` / `.json`) |
53
+ | `--format po\|json` | `po` | |
54
+ | `--namespace none\|first-segment\|keep` | `none` | `first-segment` uses the first segment of the old key (`Checkout.title` → `useExtracted('Checkout')`), `keep` the namespace the old hook was called with |
55
+ | `--project-root <dir>` | cwd | Base for the `#:` references |
56
+ | `--dry-run` | | Report only |
57
+ | `--report <file>` | | JSON report: skipped bindings with reasons, conflicts, old keys never referenced, and `keyMap` (old key → new id) |
58
+
59
+ ## Choosing the source locale
60
+
61
+ Inline messages are written in one locale and that locale must be the `messages.sourceLocale` of
62
+ your next-intl config. Pick the language your developers write copy in; it does not have to be
63
+ English. Texts are normalized to NFC before they are written, because ids hash the bytes as written
64
+ and editors save NFC: a decomposed catalog text would otherwise get a new id on its first save.
65
+ Keys whose text was normalized are listed under `normalizedKeys` in the report.
66
+
67
+ Run the codemod against the most complete catalog you have: keys missing from the source-locale
68
+ catalog cannot be inlined and are reported as `missing-key`.
69
+
70
+ ## After running
71
+
72
+ - Review the report. Dynamic keys usually become a `switch` or an object literal of `t('…')` calls
73
+ inside the component, as described in [static analysis](https://next-intl.dev/docs/usage/extraction#static-analysis).
74
+ - Old catalogs are not modified. Delete the migrated keys (or the files) once no binding reads them;
75
+ `unreferencedKeys` in the report lists keys that no static call references.
76
+ - Run `tsc`. `useExtracted` types `values` from the inline message, which is stricter than
77
+ catalog-based types, so two kinds of pre-existing looseness surface as errors:
78
+ - Values or tag handlers the source text does not declare but a translation uses (e.g. a `<br>`
79
+ only the English text has), and tag handlers passed to a plain `t()` call. These calls are
80
+ listed under `valuesNotInMessage` in the report. Add the tag or argument to the source text, or
81
+ switch to `t.rich`; removing the value would break the translation.
82
+ - Numbers passed to a plain `{arg}`, which is typed as `string`. Use `{arg, number}` in the
83
+ message (which also formats the number) or pass a string.
84
+ - Run the app once: the extractor rewrites nothing if the migration is consistent.
85
+
86
+ ## Verification
87
+
88
+ The codemod was validated on a production app:
89
+
90
+ - running next-intl's extractor over the converted sources reproduces the written catalogs byte for byte;
91
+ - every migrated key resolves to a catalog entry whose translations equal the old ones, in every locale;
92
+ - fixtures in `test/` cover the rewrite rules, conflicts, skipping and import handling.
package/dist/apply.js ADDED
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import createJsonCodec from '@eloqnt/format-json';
4
+ import createPoCodec from '@eloqnt/format-po';
5
+ /**
6
+ * `textByFile` holds the text the offsets were computed on (TypeScript strips a BOM when reading),
7
+ * so edits never land one character off; a BOM on disk is put back.
8
+ */
9
+ export function applyEdits(edits, textByFile) {
10
+ const byFile = new Map();
11
+ for (const edit of edits) {
12
+ const list = byFile.get(edit.file) ?? [];
13
+ list.push(edit);
14
+ byFile.set(edit.file, list);
15
+ }
16
+ for (const [file, list] of byFile) {
17
+ const onDisk = fs.readFileSync(file, 'utf8');
18
+ let text = textByFile.get(file) ?? onDisk;
19
+ // Apply from the end so earlier offsets stay valid.
20
+ for (const edit of list.sort((a, b) => b.start - a.start || b.end - a.end)) {
21
+ text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
22
+ }
23
+ const bom = onDisk.startsWith('') && !text.startsWith('') ? '' : '';
24
+ fs.writeFileSync(file, bom + text);
25
+ }
26
+ }
27
+ /** Merging into the previous layout would write a catalog next-intl refuses to read. */
28
+ export function assertCatalogsSupported(options) {
29
+ if (options.format !== 'po')
30
+ return;
31
+ for (const locale of options.locales) {
32
+ const file = path.join(options.outDir, `${locale}.po`);
33
+ if (fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes('X-Crowdin-SourceKey: msgstr')) {
34
+ throw new Error(`${file} uses the previous PO layout of next-intl, which current versions refuse to read. ` +
35
+ 'Migrate it (https://github.com/amannn/next-intl/pull/2393), or delete it if it has no entries, ' +
36
+ 'then run the codemod again. Nothing was changed.');
37
+ }
38
+ }
39
+ }
40
+ export function writeCatalogs(messages, options) {
41
+ if (messages.length === 0)
42
+ return;
43
+ const codec = options.format === 'po' ? createPoCodec() : createJsonCodec();
44
+ const extension = `.${options.format}`;
45
+ fs.mkdirSync(options.outDir, { recursive: true });
46
+ // Like the extractor, every locale is encoded against the source catalog: it provides the
47
+ // source text (`msgid`) and decides which entries exist. Target entries without a source are orphans.
48
+ const sourceMessagesById = new Map();
49
+ const localeList = [options.sourceLocale, ...options.locales.filter((locale) => locale !== options.sourceLocale)];
50
+ for (const locale of localeList) {
51
+ const file = path.join(options.outDir, `${locale}${extension}`);
52
+ const isSource = locale === options.sourceLocale;
53
+ const existing = new Map();
54
+ if (fs.existsSync(file)) {
55
+ for (const message of codec.decode(fs.readFileSync(file, 'utf8'), { locale, sourceLocale: options.sourceLocale })) {
56
+ existing.set(message.id, message);
57
+ }
58
+ }
59
+ const merged = new Map();
60
+ if (isSource) {
61
+ for (const [id, message] of existing)
62
+ merged.set(id, message);
63
+ }
64
+ else {
65
+ for (const [id, message] of existing)
66
+ if (sourceMessagesById.has(id))
67
+ merged.set(id, message);
68
+ }
69
+ for (const message of messages) {
70
+ const text = isSource ? message.message : (message.translations[locale] ?? '');
71
+ const previous = merged.get(message.id);
72
+ if (!previous) {
73
+ merged.set(message.id, { id: message.id, message: text, description: [], references: message.references });
74
+ continue;
75
+ }
76
+ // A translation already on disk wins over the carried-over one; references are unioned.
77
+ const seen = new Set(previous.references.map((ref) => `${ref.path}:${ref.line ?? ''}`));
78
+ merged.set(message.id, {
79
+ ...previous,
80
+ message: isSource ? text : previous.message || text,
81
+ references: [
82
+ ...previous.references,
83
+ ...message.references.filter((ref) => !seen.has(`${ref.path}:${ref.line}`))
84
+ ]
85
+ });
86
+ }
87
+ if (isSource) {
88
+ for (const [id, message] of merged)
89
+ sourceMessagesById.set(id, message);
90
+ }
91
+ fs.writeFileSync(file, codec.encode([...merged.values()], { locale, sourceMessagesById }));
92
+ }
93
+ }
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ function segmentToRegExp(segment) {
4
+ const source = segment
5
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
6
+ .replace(/\\\{locale\\\}/g, '(?<locale>[A-Za-z0-9_-]+)')
7
+ .replace(/\*/g, '[^/]*');
8
+ return new RegExp(`^${source}$`);
9
+ }
10
+ /**
11
+ * Expands a catalog path pattern such as `messages/{locale}.json` or
12
+ * `locales/{locale}/*.json` into the files that exist on disk, grouped by locale.
13
+ */
14
+ export function resolveCatalogFiles(pattern, projectRoot) {
15
+ const segments = pattern.split('/').filter(Boolean);
16
+ const result = new Map();
17
+ function walk(dir, index, locale) {
18
+ const segment = segments[index];
19
+ const isLast = index === segments.length - 1;
20
+ const isStatic = !/[{*]/.test(segment);
21
+ if (isStatic) {
22
+ const next = path.join(dir, segment);
23
+ if (!fs.existsSync(next))
24
+ return;
25
+ if (isLast)
26
+ addFile(next, locale);
27
+ else
28
+ walk(next, index + 1, locale);
29
+ return;
30
+ }
31
+ const regExp = segmentToRegExp(segment);
32
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
33
+ const match = regExp.exec(entry.name);
34
+ if (!match)
35
+ continue;
36
+ const nextLocale = match.groups?.locale ?? locale;
37
+ const next = path.join(dir, entry.name);
38
+ if (isLast) {
39
+ if (entry.isFile())
40
+ addFile(next, nextLocale);
41
+ }
42
+ else if (entry.isDirectory()) {
43
+ walk(next, index + 1, nextLocale);
44
+ }
45
+ }
46
+ }
47
+ function addFile(file, locale) {
48
+ if (!locale)
49
+ throw new Error(`Pattern "${pattern}" must contain {locale}.`);
50
+ const list = result.get(locale) ?? [];
51
+ list.push(file);
52
+ result.set(locale, list);
53
+ }
54
+ walk(projectRoot, 0, undefined);
55
+ for (const list of result.values())
56
+ list.sort();
57
+ return result;
58
+ }
59
+ function flatten(value, prefix, strings, others) {
60
+ if (typeof value === 'string') {
61
+ strings.set(prefix, value);
62
+ }
63
+ else if (value && typeof value === 'object' && !Array.isArray(value)) {
64
+ for (const [key, child] of Object.entries(value)) {
65
+ flatten(child, prefix ? `${prefix}.${key}` : key, strings, others);
66
+ }
67
+ }
68
+ else {
69
+ others.set(prefix, value);
70
+ }
71
+ }
72
+ export function loadCatalogs(pattern, projectRoot, sourceLocale) {
73
+ const filesByLocale = resolveCatalogFiles(pattern, projectRoot);
74
+ if (!filesByLocale.has(sourceLocale)) {
75
+ throw new Error(`No catalog found for source locale "${sourceLocale}" with pattern "${pattern}".`);
76
+ }
77
+ const byLocale = new Map();
78
+ const nonString = new Map();
79
+ const collisions = [];
80
+ for (const [locale, files] of filesByLocale) {
81
+ const merged = new Map();
82
+ const fileByKey = new Map();
83
+ for (const file of files) {
84
+ const strings = new Map();
85
+ const others = new Map();
86
+ flatten(JSON.parse(fs.readFileSync(file, 'utf8')), '', strings, others);
87
+ for (const [key, value] of strings) {
88
+ const previous = merged.get(key);
89
+ if (previous !== undefined && previous !== value) {
90
+ collisions.push({ locale, key, files: [fileByKey.get(key), file] });
91
+ }
92
+ merged.set(key, value);
93
+ fileByKey.set(key, file);
94
+ }
95
+ if (locale === sourceLocale) {
96
+ for (const [key, value] of others)
97
+ nonString.set(key, value);
98
+ }
99
+ }
100
+ byLocale.set(locale, merged);
101
+ }
102
+ const locales = [
103
+ sourceLocale,
104
+ ...[...filesByLocale.keys()].filter((locale) => locale !== sourceLocale).sort()
105
+ ];
106
+ return { locales, byLocale, nonString, collisions, filesByLocale };
107
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { applyEdits, assertCatalogsSupported, writeCatalogs } from './apply.js';
6
+ import { loadCatalogs } from './catalog.js';
7
+ import { collectSourceFiles } from './files.js';
8
+ import { buildPlan } from './plan.js';
9
+ import { scanFiles } from './scan.js';
10
+ const { values } = parseArgs({
11
+ options: {
12
+ src: { type: 'string', multiple: true, default: ['src'] },
13
+ messages: { type: 'string' },
14
+ 'source-locale': { type: 'string' },
15
+ out: { type: 'string', default: 'messages' },
16
+ format: { type: 'string', default: 'po' },
17
+ namespace: { type: 'string', default: 'none' },
18
+ 'project-root': { type: 'string', default: process.cwd() },
19
+ 'dry-run': { type: 'boolean', default: false },
20
+ report: { type: 'string' },
21
+ help: { type: 'boolean', default: false }
22
+ }
23
+ });
24
+ if (values.help || !values.messages || !values['source-locale']) {
25
+ console.log(`Usage: next-intl-codemod --messages <pattern> --source-locale <locale> [options]
26
+
27
+ Rewrites useTranslations/getTranslations calls with static keys to useExtracted/getExtracted
28
+ inline messages and carries the existing translations over to extraction catalogs.
29
+
30
+ --messages <pattern> Old catalogs, e.g. "messages/{locale}.json" or "locales/{locale}/*.json"
31
+ --source-locale <locale> Locale whose texts become the inline messages
32
+ --src <dir> Source directory (repeatable, default: src)
33
+ --out <dir> Where to write the extraction catalogs (default: messages)
34
+ --format po|json Catalog format (default: po)
35
+ --namespace none|first-segment|keep
36
+ none: useExtracted(); first-segment: first key segment;
37
+ keep: the namespace the old hook used (default: none)
38
+ --project-root <dir> Base for file references (default: cwd)
39
+ --dry-run Plan and report only
40
+ --report <file> Write the JSON report here`);
41
+ process.exit(values.help ? 0 : 1);
42
+ }
43
+ const projectRoot = path.resolve(values['project-root']);
44
+ const format = values.format;
45
+ const namespace = values.namespace;
46
+ if (!['po', 'json'].includes(format))
47
+ throw new Error(`Unknown format: ${format}`);
48
+ if (!['none', 'first-segment', 'keep'].includes(namespace))
49
+ throw new Error(`Unknown namespace strategy: ${namespace}`);
50
+ const catalogs = loadCatalogs(values.messages, projectRoot, values['source-locale']);
51
+ assertCatalogsSupported({ outDir: path.resolve(projectRoot, values.out), format, locales: catalogs.locales });
52
+ const files = [];
53
+ for (const dir of values.src)
54
+ collectSourceFiles(path.resolve(projectRoot, dir), files);
55
+ const scanned = scanFiles(files);
56
+ const plan = buildPlan(scanned, catalogs, { projectRoot, sourceLocale: values['source-locale'], namespace });
57
+ const { report } = plan;
58
+ console.log([
59
+ `files: ${report.files.changed}/${report.files.scanned} changed`,
60
+ `bindings: ${report.bindings.converted} converted, ${report.bindings.skipped} skipped`,
61
+ `calls: ${report.calls.converted} converted`,
62
+ `messages: ${report.messages.total} (${report.messages.explicitIds} with explicit ids from ${report.conflicts.length} conflicting texts)`,
63
+ `old keys never referenced statically: ${report.unreferencedKeys.length}`
64
+ ].join('\n'));
65
+ const reasons = new Map();
66
+ for (const binding of report.skipped) {
67
+ for (const problem of binding.problems) {
68
+ const reason = problem.reason.split(':')[0];
69
+ reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
70
+ }
71
+ }
72
+ if (reasons.size > 0) {
73
+ console.log('skipped because:');
74
+ for (const [reason, count] of [...reasons].sort((a, b) => b[1] - a[1]))
75
+ console.log(` ${count}× ${reason}`);
76
+ }
77
+ if (values.report) {
78
+ fs.writeFileSync(values.report, JSON.stringify(report, null, 2));
79
+ console.log(`report: ${values.report}`);
80
+ }
81
+ if (report.valuesNotInMessage.length > 0) {
82
+ console.log(`calls passing values the source text does not declare (expect tsc errors, see report): ${report.valuesNotInMessage.length}`);
83
+ }
84
+ if (report.files.withoutNextIntlImport.length > 0) {
85
+ console.log(`files mentioning the hooks without a next-intl import (left untouched): ${report.files.withoutNextIntlImport.length}`);
86
+ }
87
+ if (!values['dry-run']) {
88
+ applyEdits(plan.edits, new Map(scanned.map((scannedFile) => [scannedFile.file, scannedFile.text])));
89
+ writeCatalogs(plan.messages, {
90
+ outDir: path.resolve(projectRoot, values.out),
91
+ format,
92
+ sourceLocale: values['source-locale'],
93
+ locales: catalogs.locales
94
+ });
95
+ console.log(`wrote ${catalogs.locales.length} catalogs to ${values.out}`);
96
+ }
package/dist/files.js ADDED
@@ -0,0 +1,20 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
4
+ const IGNORED_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'out']);
5
+ /** Source files that mention one of the old hooks; everything else cannot change. */
6
+ export function collectSourceFiles(dir, out = []) {
7
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
8
+ if (entry.isDirectory()) {
9
+ if (!IGNORED_DIRS.has(entry.name))
10
+ collectSourceFiles(path.join(dir, entry.name), out);
11
+ }
12
+ else if (SOURCE_EXTENSIONS.has(path.extname(entry.name)) && !entry.name.endsWith('.d.ts')) {
13
+ const file = path.join(dir, entry.name);
14
+ const text = fs.readFileSync(file, 'utf8');
15
+ if (text.includes('useTranslations') || text.includes('getTranslations'))
16
+ out.push(file);
17
+ }
18
+ }
19
+ return out;
20
+ }
package/dist/id.js ADDED
@@ -0,0 +1,5 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** Mirrors `packages/swc-plugin-extractor/src/key_generator.rs`. */
3
+ export function generateId(message) {
4
+ return createHash('sha512').update(message, 'utf8').digest('base64url').slice(0, 6);
5
+ }
package/dist/plan.js ADDED
@@ -0,0 +1,272 @@
1
+ import path from 'node:path';
2
+ import { generateId } from './id.js';
3
+ import { EXTRACTED_BY_HOOK, MODULE_BY_HOOK } from './scan.js';
4
+ export function toStringLiteral(text) {
5
+ const escaped = text
6
+ .replace(/\\/g, '\\\\')
7
+ .replace(/'/g, "\\'")
8
+ .replace(/\n/g, '\\n')
9
+ .replace(/\r/g, '\\r')
10
+ .replace(/\t/g, '\\t')
11
+ .replace(/\u2028/g, '\\u2028')
12
+ .replace(/\u2029/g, '\\u2029')
13
+ .replace(/[\u0000-\u001f\u007f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
14
+ return `'${escaped}'`;
15
+ }
16
+ function resolveBinding(binding, catalogs, sourceLocale) {
17
+ const source = catalogs.byLocale.get(sourceLocale);
18
+ const problems = [...binding.problems];
19
+ const resolved = [];
20
+ // Without a static namespace no key can be looked up; reporting each call would only repeat the cause.
21
+ if (problems.some((problem) => problem.reason === 'dynamic-namespace'))
22
+ return { resolved, problems };
23
+ for (const call of binding.calls) {
24
+ const fullKey = binding.namespace ? `${binding.namespace}.${call.key}` : call.key;
25
+ const raw = source.get(fullKey);
26
+ // Ids hash the bytes as written. Editors save NFC, so a decomposed catalog text would get a new
27
+ // id (and lose its translations) on the first save; normalize up front instead.
28
+ const text = raw?.normalize('NFC');
29
+ if (text !== undefined && text.trim() === '') {
30
+ problems.push({ reason: `empty-message: ${fullKey}`, line: call.line, column: call.column });
31
+ continue;
32
+ }
33
+ if (text === undefined) {
34
+ const reason = catalogs.nonString.has(fullKey)
35
+ ? 'non-string-message'
36
+ : [...source.keys()].some((key) => key.startsWith(`${fullKey}.`))
37
+ ? 'key-refers-to-namespace-object'
38
+ : 'missing-key';
39
+ problems.push({ reason: `${reason}: ${fullKey}`, line: call.line, column: call.column });
40
+ continue;
41
+ }
42
+ resolved.push({ call, fullKey, text, normalized: raw !== text });
43
+ }
44
+ return { resolved, problems };
45
+ }
46
+ function pickNamespace(binding, resolved, strategy) {
47
+ if (strategy === 'none')
48
+ return undefined;
49
+ if (strategy === 'keep')
50
+ return binding.namespace;
51
+ // A single-segment key has no namespace to take; mixing it with namespaced keys yields none either.
52
+ const firstSegments = new Set(resolved.map(({ fullKey }) => (fullKey.includes('.') ? fullKey.split('.')[0] : undefined)));
53
+ return firstSegments.size === 1 ? [...firstSegments][0] : undefined;
54
+ }
55
+ export function buildPlan(scanned, catalogs, options) {
56
+ const targetLocales = catalogs.locales.filter((locale) => locale !== options.sourceLocale);
57
+ const edits = [];
58
+ const skipped = [];
59
+ const referencedKeys = new Set();
60
+ const groups = new Map();
61
+ // Call-site edits are emitted once ids are known, so remember where each call goes.
62
+ const pendingCalls = [];
63
+ let convertedBindings = 0;
64
+ let convertedCalls = 0;
65
+ const changedFiles = new Set();
66
+ const normalizedKeys = new Set();
67
+ const valuesNotInMessage = [];
68
+ for (const scannedFile of scanned) {
69
+ const { file, text } = scannedFile;
70
+ // Per hook kind: which bindings stay on the old hook decides whether the
71
+ // import is renamed or extended.
72
+ const kept = new Set();
73
+ const converted = new Set();
74
+ const hookEdits = [];
75
+ for (const binding of scannedFile.bindings) {
76
+ const { resolved, problems } = resolveBinding(binding, catalogs, options.sourceLocale);
77
+ for (const { fullKey } of resolved)
78
+ referencedKeys.add(fullKey);
79
+ if (problems.length > 0) {
80
+ kept.add(binding.hook);
81
+ const { line, column } = positionFromOffset(text, binding.hookCallRange.start);
82
+ skipped.push({ file: relative(options.projectRoot, file), name: binding.name, hook: binding.hook, line, column, problems });
83
+ continue;
84
+ }
85
+ converted.add(binding.hook);
86
+ convertedBindings++;
87
+ const namespace = pickNamespace(binding, resolved, options.namespace);
88
+ hookEdits.push({ binding, namespace });
89
+ for (const { call, fullKey, text: messageText, normalized } of resolved) {
90
+ convertedCalls++;
91
+ if (normalized)
92
+ normalizedKeys.add(fullKey);
93
+ if (call.valueKeys) {
94
+ const { args, tags } = icuNames(messageText);
95
+ // Tag handlers only exist on t.rich / t.markup; a plain t() call cannot take them.
96
+ const declared = call.method === 'call' ? args : new Set([...args, ...tags]);
97
+ const extra = call.valueKeys.filter((name) => !declared.has(name));
98
+ if (extra.length > 0) {
99
+ valuesNotInMessage.push({ file: relative(options.projectRoot, file), line: call.line, column: call.column, key: fullKey, values: extra });
100
+ }
101
+ }
102
+ const groupKey = `${namespace ?? ''}\u0000${messageText}`;
103
+ let group = groups.get(groupKey);
104
+ if (!group) {
105
+ group = { namespace, text: messageText, entries: new Map() };
106
+ groups.set(groupKey, group);
107
+ }
108
+ let entry = group.entries.get(fullKey);
109
+ if (!entry) {
110
+ const translations = {};
111
+ for (const locale of targetLocales) {
112
+ translations[locale] = catalogs.byLocale.get(locale)?.get(fullKey) ?? '';
113
+ }
114
+ entry = { fullKey, translations, sites: [] };
115
+ group.entries.set(fullKey, entry);
116
+ }
117
+ entry.sites.push({ file, call });
118
+ pendingCalls.push({ file, call, groupKey, fullKey });
119
+ }
120
+ }
121
+ if (hookEdits.length === 0)
122
+ continue;
123
+ changedFiles.add(file);
124
+ for (const hook of converted) {
125
+ const extractedName = EXTRACTED_BY_HOOK[hook];
126
+ const oldName = hook === 'use' ? 'useTranslations' : 'getTranslations';
127
+ const moduleImports = scannedFile.imports.filter((info) => info.module === MODULE_BY_HOOK[hook]);
128
+ const importInfo = moduleImports.find((info) => info.specifiers.some((spec) => spec.imported === oldName)) ?? moduleImports[0];
129
+ const existing = moduleImports.flatMap((info) => info.specifiers).find((spec) => spec.imported === extractedName);
130
+ const hookSpecifier = importInfo?.specifiers.find((spec) => spec.imported === oldName);
131
+ // The old specifier can go only when no binding and no other reference still needs it.
132
+ const oldSpecifierUnused = hookSpecifier !== undefined && !kept.has(hook) && hookSpecifier.otherUses === 0;
133
+ let localName = extractedName;
134
+ if (existing) {
135
+ localName = existing.local;
136
+ if (oldSpecifierUnused) {
137
+ // The old hook is no longer used: drop its specifier together with one separating comma.
138
+ const specifiers = importInfo.specifiers;
139
+ const index = specifiers.indexOf(hookSpecifier);
140
+ const start = index > 0 ? specifiers[index - 1].elementRange.end : hookSpecifier.elementRange.start;
141
+ const end = index > 0 ? hookSpecifier.elementRange.end : specifiers[index + 1].elementRange.start;
142
+ edits.push({ file, start, end, text: '' });
143
+ }
144
+ }
145
+ else if (oldSpecifierUnused) {
146
+ // Every binding of this hook is converted: rename the specifier in place.
147
+ edits.push({ file, ...hookSpecifier.importedRange, text: extractedName });
148
+ if (hookSpecifier.local !== hookSpecifier.imported)
149
+ localName = hookSpecifier.local;
150
+ }
151
+ else if (importInfo) {
152
+ edits.push({ file, start: importInfo.appendPosition, end: importInfo.appendPosition, text: `, ${extractedName}` });
153
+ }
154
+ for (const { binding, namespace } of hookEdits) {
155
+ if (binding.hook !== hook)
156
+ continue;
157
+ let args = '';
158
+ if (binding.otherHookProps) {
159
+ const props = [...binding.otherHookProps];
160
+ if (namespace !== undefined)
161
+ props.push(`namespace: ${toStringLiteral(namespace)}`);
162
+ args = props.length > 0 ? `{${props.join(', ')}}` : '';
163
+ }
164
+ else if (namespace !== undefined) {
165
+ args = toStringLiteral(namespace);
166
+ }
167
+ edits.push({ file, ...binding.hookCallRange, text: `${localName}(${args})` });
168
+ }
169
+ }
170
+ }
171
+ // Resolve ids: one message per (namespace, text) unless the old translations
172
+ // disagree, in which case each distinct translation set keeps an explicit id.
173
+ const messages = [];
174
+ const conflicts = [];
175
+ const idByFullKey = new Map();
176
+ const usedIds = new Set();
177
+ for (const group of groups.values()) {
178
+ const clusters = new Map();
179
+ for (const entry of group.entries.values()) {
180
+ const signature = JSON.stringify(targetLocales.map((locale) => entry.translations[locale]));
181
+ const list = clusters.get(signature) ?? [];
182
+ list.push(entry);
183
+ clusters.set(signature, list);
184
+ }
185
+ const isConflict = clusters.size > 1;
186
+ const conflict = { namespace: group.namespace, message: group.text, clusters: [] };
187
+ const prefix = group.namespace ? `${group.namespace}.` : '';
188
+ for (const entries of clusters.values()) {
189
+ entries.sort((a, b) => a.fullKey.localeCompare(b.fullKey, 'en'));
190
+ let localId = generateId(group.text);
191
+ if (isConflict) {
192
+ const first = entries[0].fullKey;
193
+ localId = first.startsWith(prefix) ? first.slice(prefix.length) : first;
194
+ }
195
+ const id = `${prefix}${localId}`;
196
+ if (usedIds.has(id))
197
+ throw new Error(`Generated id collision: ${id}`);
198
+ usedIds.add(id);
199
+ const references = [];
200
+ for (const entry of entries) {
201
+ idByFullKey.set(entry.fullKey, { localId, explicit: isConflict });
202
+ for (const { file, call } of entry.sites) {
203
+ references.push({ path: relative(options.projectRoot, file), line: call.line, column: call.column });
204
+ }
205
+ }
206
+ messages.push({
207
+ id,
208
+ message: group.text,
209
+ references: references.sort(compareReferences),
210
+ translations: entries[0].translations,
211
+ explicitId: isConflict,
212
+ oldKeys: entries.map((entry) => entry.fullKey)
213
+ });
214
+ conflict.clusters.push({ id, oldKeys: entries.map((entry) => entry.fullKey), translations: entries[0].translations });
215
+ }
216
+ if (isConflict)
217
+ conflicts.push(conflict);
218
+ }
219
+ for (const { file, call, fullKey, groupKey } of pendingCalls) {
220
+ const { localId, explicit } = idByFullKey.get(fullKey);
221
+ const text = groups.get(groupKey).text;
222
+ const replacement = explicit
223
+ ? `{id: ${toStringLiteral(localId)}, message: ${toStringLiteral(text)}}`
224
+ : toStringLiteral(text);
225
+ edits.push({ file, ...call.argRange, text: replacement });
226
+ }
227
+ const source = catalogs.byLocale.get(options.sourceLocale);
228
+ const report = {
229
+ files: {
230
+ scanned: scanned.length,
231
+ changed: changedFiles.size,
232
+ withoutNextIntlImport: scanned
233
+ .filter((scannedFile) => scannedFile.imports.length === 0 && scannedFile.hookIdentifiers > 0)
234
+ .map((scannedFile) => relative(options.projectRoot, scannedFile.file))
235
+ },
236
+ bindings: { converted: convertedBindings, skipped: skipped.length },
237
+ calls: { converted: convertedCalls },
238
+ messages: { total: messages.length, explicitIds: messages.filter((m) => m.explicitId).length },
239
+ normalizedKeys: [...normalizedKeys],
240
+ valuesNotInMessage,
241
+ skipped,
242
+ conflicts,
243
+ nonStringKeys: [...catalogs.nonString.keys()],
244
+ catalogCollisions: catalogs.collisions,
245
+ unreferencedKeys: [...source.keys()].filter((key) => !referencedKeys.has(key)),
246
+ keyMap: Object.fromEntries(messages.flatMap((m) => m.oldKeys.map((key) => [key, m.id])))
247
+ };
248
+ return { edits, messages, report };
249
+ }
250
+ /** Argument and tag names an ICU message declares: `{count, plural, …}`, `<link>…</link>`. */
251
+ function icuNames(message) {
252
+ const args = new Set();
253
+ const tags = new Set();
254
+ for (const match of message.matchAll(/\{\s*([A-Za-z0-9_]+)/g))
255
+ args.add(match[1]);
256
+ for (const match of message.matchAll(/<([A-Za-z0-9_]+)[\s/>]/g))
257
+ tags.add(match[1]);
258
+ return { args, tags };
259
+ }
260
+ /** Same order as the extractor (`compareReferences` in next-intl), so a later extraction run rewrites nothing. */
261
+ function compareReferences(a, b) {
262
+ return a.path.localeCompare(b.path, 'en') || a.line - b.line || a.column - b.column;
263
+ }
264
+ function relative(projectRoot, file) {
265
+ return path.relative(projectRoot, file).split(path.sep).join('/');
266
+ }
267
+ function positionFromOffset(text, offset) {
268
+ const before = text.slice(0, offset);
269
+ const line = before.split('\n').length;
270
+ const column = offset - (before.lastIndexOf('\n') + 1);
271
+ return { line, column };
272
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,280 @@
1
+ import ts from 'typescript';
2
+ const HOOK_BY_IMPORT = {
3
+ useTranslations: 'use',
4
+ getTranslations: 'get'
5
+ };
6
+ const EXTRACTED_BY_HOOK = {
7
+ use: 'useExtracted',
8
+ get: 'getExtracted'
9
+ };
10
+ const MODULE_BY_HOOK = {
11
+ use: 'next-intl',
12
+ get: 'next-intl/server'
13
+ };
14
+ export { EXTRACTED_BY_HOOK, MODULE_BY_HOOK };
15
+ function staticString(node) {
16
+ if (!node)
17
+ return undefined;
18
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
19
+ return node.text;
20
+ }
21
+ return undefined;
22
+ }
23
+ function rangeOf(node, sourceFile) {
24
+ return { start: node.getStart(sourceFile), end: node.getEnd() };
25
+ }
26
+ function positionOf(node, sourceFile) {
27
+ const start = node.getStart(sourceFile);
28
+ const { line } = sourceFile.getLineAndCharacterOfPosition(start);
29
+ // The SWC extractor counts columns in code points, TypeScript in UTF-16 units.
30
+ const lineStart = sourceFile.getPositionOfLineAndCharacter(line, 0);
31
+ const column = [...sourceFile.text.slice(lineStart, start)].length;
32
+ return { line: line + 1, column };
33
+ }
34
+ /**
35
+ * Finds the variable the hook result is bound to:
36
+ * `const t = useTranslations()`, `const t = await getTranslations()` or
37
+ * `const [a, b] = await Promise.all([getTranslations('a'), getTranslations('b')])`.
38
+ */
39
+ function findBindingName(call) {
40
+ let node = call;
41
+ if (ts.isAwaitExpression(node.parent))
42
+ node = node.parent;
43
+ if (ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) {
44
+ return node.parent.name;
45
+ }
46
+ if (ts.isArrayLiteralExpression(node.parent)) {
47
+ const index = node.parent.elements.indexOf(node);
48
+ let declaration = node.parent.parent;
49
+ if (!ts.isCallExpression(declaration))
50
+ return undefined;
51
+ declaration = declaration.parent;
52
+ if (ts.isAwaitExpression(declaration))
53
+ declaration = declaration.parent;
54
+ if (ts.isVariableDeclaration(declaration) &&
55
+ ts.isArrayBindingPattern(declaration.name)) {
56
+ const element = declaration.name.elements[index];
57
+ if (element && ts.isBindingElement(element) && ts.isIdentifier(element.name)) {
58
+ return element.name;
59
+ }
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+ function staticPropertyNames(node) {
65
+ if (!node || !ts.isObjectLiteralExpression(node))
66
+ return undefined;
67
+ const names = [];
68
+ for (const property of node.properties) {
69
+ const name = property.name;
70
+ if (!name || !(ts.isIdentifier(name) || ts.isStringLiteral(name)))
71
+ return undefined;
72
+ names.push(name.text);
73
+ }
74
+ return names;
75
+ }
76
+ /** True for an identifier inside the trailing array argument of a `useXxx(...)` call. */
77
+ function isHookDependency(reference) {
78
+ const array = reference.parent;
79
+ if (!ts.isArrayLiteralExpression(array) || !ts.isCallExpression(array.parent))
80
+ return false;
81
+ const call = array.parent;
82
+ if (call.arguments[call.arguments.length - 1] !== array)
83
+ return false;
84
+ const callee = call.expression;
85
+ const name = ts.isIdentifier(callee)
86
+ ? callee.text
87
+ : ts.isPropertyAccessExpression(callee)
88
+ ? callee.name.text
89
+ : '';
90
+ return /^use[A-Z]/.test(name);
91
+ }
92
+ export function scanFiles(files) {
93
+ const program = ts.createProgram(files, {
94
+ allowJs: true,
95
+ jsx: ts.JsxEmit.Preserve,
96
+ noResolve: true,
97
+ noLib: true,
98
+ target: ts.ScriptTarget.ESNext,
99
+ module: ts.ModuleKind.ESNext
100
+ });
101
+ const checker = program.getTypeChecker();
102
+ const result = [];
103
+ for (const file of files) {
104
+ const sourceFile = program.getSourceFile(file);
105
+ if (!sourceFile)
106
+ continue;
107
+ result.push(scanSourceFile(sourceFile, checker));
108
+ }
109
+ return result;
110
+ }
111
+ function scanSourceFile(sourceFile, checker) {
112
+ const imports = [];
113
+ const hookByImportSymbol = new Map();
114
+ const hookSpecifiers = [];
115
+ for (const statement of sourceFile.statements) {
116
+ if (!ts.isImportDeclaration(statement))
117
+ continue;
118
+ const moduleName = staticString(statement.moduleSpecifier);
119
+ if (moduleName !== 'next-intl' && moduleName !== 'next-intl/server')
120
+ continue;
121
+ const named = statement.importClause?.namedBindings;
122
+ if (!named || !ts.isNamedImports(named))
123
+ continue;
124
+ const info = {
125
+ module: moduleName,
126
+ specifiers: [],
127
+ appendPosition: named.elements[named.elements.length - 1]?.getEnd() ?? named.getEnd() - 1
128
+ };
129
+ for (const element of named.elements) {
130
+ const importedNode = element.propertyName ?? element.name;
131
+ const imported = importedNode.text;
132
+ const specifier = {
133
+ imported,
134
+ local: element.name.text,
135
+ importedRange: rangeOf(importedNode, sourceFile),
136
+ elementRange: rangeOf(element, sourceFile),
137
+ otherUses: 0
138
+ };
139
+ info.specifiers.push(specifier);
140
+ const hook = HOOK_BY_IMPORT[imported];
141
+ if (hook && MODULE_BY_HOOK[hook] === moduleName) {
142
+ const symbol = checker.getSymbolAtLocation(element.name);
143
+ if (symbol) {
144
+ hookByImportSymbol.set(symbol, hook);
145
+ hookSpecifiers.push({ specifier, symbol, nameNode: element.name });
146
+ }
147
+ }
148
+ }
149
+ imports.push(info);
150
+ }
151
+ const bindings = [];
152
+ let hookIdentifiers = 0;
153
+ const countHookIdentifiers = (node) => {
154
+ if (ts.isIdentifier(node) && node.text in HOOK_BY_IMPORT)
155
+ hookIdentifiers++;
156
+ ts.forEachChild(node, countHookIdentifiers);
157
+ };
158
+ countHookIdentifiers(sourceFile);
159
+ const result = { file: sourceFile.fileName, text: sourceFile.text, imports, bindings, hookIdentifiers };
160
+ if (hookByImportSymbol.size === 0)
161
+ return result;
162
+ // One pass collects every identifier by symbol, so references of a binding
163
+ // are a lookup instead of a traversal per binding.
164
+ const identifiersBySymbol = new Map();
165
+ const hookCalls = [];
166
+ const visit = (node) => {
167
+ if (ts.isIdentifier(node)) {
168
+ const symbol = ts.isShorthandPropertyAssignment(node.parent)
169
+ ? checker.getShorthandAssignmentValueSymbol(node.parent)
170
+ : checker.getSymbolAtLocation(node);
171
+ if (symbol) {
172
+ const list = identifiersBySymbol.get(symbol) ?? [];
173
+ list.push(node);
174
+ identifiersBySymbol.set(symbol, list);
175
+ }
176
+ }
177
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
178
+ const symbol = checker.getSymbolAtLocation(node.expression);
179
+ const hook = symbol && hookByImportSymbol.get(symbol);
180
+ if (hook)
181
+ hookCalls.push({ call: node, hook });
182
+ }
183
+ ts.forEachChild(node, visit);
184
+ };
185
+ visit(sourceFile);
186
+ const hookCallees = new Set(hookCalls.map(({ call }) => call.expression));
187
+ for (const { specifier, symbol, nameNode } of hookSpecifiers) {
188
+ specifier.otherUses = (identifiersBySymbol.get(symbol) ?? []).filter((identifier) => identifier !== nameNode && !hookCallees.has(identifier)).length;
189
+ }
190
+ for (const { call, hook } of hookCalls) {
191
+ const binding = {
192
+ name: '',
193
+ hook,
194
+ hookCallRange: rangeOf(call, sourceFile),
195
+ calls: [],
196
+ problems: []
197
+ };
198
+ bindings.push(binding);
199
+ const position = positionOf(call, sourceFile);
200
+ const arg = call.arguments[0];
201
+ if (arg) {
202
+ if (ts.isObjectLiteralExpression(arg)) {
203
+ binding.otherHookProps = [];
204
+ for (const property of arg.properties) {
205
+ const name = property.name && ts.isIdentifier(property.name) ? property.name.text : undefined;
206
+ if (name === 'namespace' && ts.isPropertyAssignment(property)) {
207
+ const namespace = staticString(property.initializer);
208
+ if (namespace === undefined) {
209
+ binding.problems.push({ reason: 'dynamic-namespace', ...position });
210
+ }
211
+ binding.namespace = namespace;
212
+ }
213
+ else {
214
+ binding.otherHookProps.push(property.getText(sourceFile));
215
+ }
216
+ }
217
+ }
218
+ else {
219
+ const namespace = staticString(arg);
220
+ if (namespace === undefined) {
221
+ binding.problems.push({ reason: 'dynamic-namespace', ...position });
222
+ }
223
+ binding.namespace = namespace;
224
+ }
225
+ }
226
+ const nameNode = findBindingName(call);
227
+ if (!nameNode) {
228
+ binding.problems.push({ reason: 'hook-result-not-bound-to-variable', ...position });
229
+ continue;
230
+ }
231
+ binding.name = nameNode.text;
232
+ const symbol = checker.getSymbolAtLocation(nameNode);
233
+ const references = (symbol && identifiersBySymbol.get(symbol)) ?? [];
234
+ for (const reference of references) {
235
+ if (reference === nameNode)
236
+ continue;
237
+ const referencePosition = positionOf(reference, sourceFile);
238
+ let callExpression;
239
+ let method = 'call';
240
+ if (ts.isCallExpression(reference.parent) && reference.parent.expression === reference) {
241
+ callExpression = reference.parent;
242
+ }
243
+ else if (ts.isPropertyAccessExpression(reference.parent) &&
244
+ reference.parent.expression === reference &&
245
+ ts.isCallExpression(reference.parent.parent) &&
246
+ reference.parent.parent.expression === reference.parent) {
247
+ const property = reference.parent.name.text;
248
+ if (property === 'rich' || property === 'markup' || property === 'has' || property === 'raw') {
249
+ method = property;
250
+ callExpression = reference.parent.parent;
251
+ }
252
+ }
253
+ if (!callExpression) {
254
+ // `useCallback(() => t('x'), [t])`: the dependency list reads `t` but never calls it.
255
+ if (!isHookDependency(reference)) {
256
+ binding.problems.push({ reason: 'translator-used-outside-call', ...referencePosition });
257
+ }
258
+ continue;
259
+ }
260
+ if (method === 'has' || method === 'raw') {
261
+ binding.problems.push({ reason: `t.${method}-not-supported-by-extraction`, ...referencePosition });
262
+ continue;
263
+ }
264
+ const keyNode = callExpression.arguments[0];
265
+ const key = staticString(keyNode);
266
+ if (key === undefined) {
267
+ binding.problems.push({ reason: 'dynamic-key', ...referencePosition });
268
+ continue;
269
+ }
270
+ binding.calls.push({
271
+ method,
272
+ argRange: rangeOf(keyNode, sourceFile),
273
+ key,
274
+ valueKeys: staticPropertyNames(callExpression.arguments[1]),
275
+ ...positionOf(callExpression, sourceFile)
276
+ });
277
+ }
278
+ }
279
+ return result;
280
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "next-intl-codemod",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "next-intl-codemod": "dist/cli.js"
7
+ },
8
+ "scripts": {
9
+ "test": "vitest run",
10
+ "codemod": "tsx src/cli.ts",
11
+ "build": "tsc -p tsconfig.build.json",
12
+ "prepublishOnly": "pnpm test && pnpm build"
13
+ },
14
+ "dependencies": {
15
+ "@eloqnt/config": "^0.1.1",
16
+ "@eloqnt/format-json": "^0.1.0",
17
+ "@eloqnt/format-po": "^0.1.1",
18
+ "typescript": "^5.9.0"
19
+ },
20
+ "devDependencies": {
21
+ "@swc/core": "^1.16.0",
22
+ "@types/node": "^26.6.5",
23
+ "next-intl": "^4.14.9",
24
+ "next-intl-swc-plugin-extractor": "^4.14.9",
25
+ "tsx": "^4.20.0",
26
+ "vitest": "^3.2.0"
27
+ },
28
+ "license": "MIT",
29
+ "description": "Migrates next-intl useTranslations/getTranslations with static keys to useExtracted/getExtracted and carries existing translations over.",
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/seob717/next-intl-codemod.git"
36
+ }
37
+ }