Babelize 如何把 Next.js 應用程式的硬編碼字串轉成經審閲的 i18n Pull Request
How We Turn a Next.js App's Hardcoded Strings Into a Reviewed i18n Pull Request
Babelize 透過 AST 擷取 Next.js 應用程式中的硬編碼字串,轉成 i18n 訊息,並提供本地 diff 或 pull request 供人工審閲。
TL;DR: Babelize parses every file with a Babel AST, extracts only user-facing text (whole JSX sentences become ICU messages with placeholders, tags and plurals), names keys from the file path and first words, hashes source text into a lockfile so reruns only translate what changed, validates every LLM translation for lost placeholders, broken ICU and untranslated output, then rewrites components to use next-intl and opens a diff or pull request for review.
Every React app starts with hardcoded text, and nobody wants to spend a sprint moving 900 strings into JSON files by hand. This is how Babelize does it: an AST pass that finds the text worth translating, keys that stay stable between runs, a lockfile so reruns cost nothing, an LLM whose output we refuse to trust, and a code rewrite that keeps your formatting. Plus the cases where it deliberately stops and asks you.
The Problem: Text Lives Inside Components
A typical Next.js page looks like this:
// src/app/dashboard/page.tsx
export default function Dashboard({ user, count }) {
return (
<main>
<h1>Welcome back, {user.name}</h1>
<p>Read the <Link href="/terms">terms</Link> before you continue.</p>
<input placeholder="Search projects" className="input" />
<span>{count} projects</span>
</main>
);
}
Find-and-replace can't localize this. "Welcome back," and {user.name} are one sentence, and other languages put the name somewhere else. The link sits in the middle of a sentence. placeholder is user-facing, className isn't. "projects" needs a plural form, and Polish has four of them. Doing it properly means understanding the code, not the text.
Step 1: Find the Strings with an AST, Not a Regex
Every file is parsed with @babel/parser (JSX and TypeScript plugins, with error recovery so one broken file doesn't stop the run). Then visitors walk the tree looking for JSX text, whole JSX elements, string literals, template literals, + concatenations and both branches of ternaries.
Most of the work is deciding what not to extract:
-
Attributes: only an allowlist is user-facing:
title,placeholder,alt,aria-label,aria-description,label,description(plusaccessibilityLabelandaccessibilityHintfor React Native).className,hrefand everything else is left alone. -
Objects: a string under a copy-like key (
label,title,message) is UI text; under a data-like key (href,url,id,className) it's data. -
Calls:
console.*,throw,new Error(...)and hook arguments are skipped, along with almost every other call argument. -
Values that look like data: URLs and paths, CSS selectors, colors, ISO dates, all-caps identifiers like
SKU12345, pure numbers and symbols, and text under three characters. -
Files:
node_modules, build output, tests, specs and.d.tsfiles.
Sentences, not fragments
The key decision: a JSX element with mixed children becomes one ICU message, so translators see the whole sentence. Interpolations become named placeholders, and inline tags like <Link>, <b> or <code> become tags inside the message:
"Welcome back, {name}"
"Read the <link>terms</link> before you continue."
"{count, plural, one {# project} other {# projects}}"
The plural comes from a deliberately narrow rule: exactly one placeholder with a count-like name (count, total, n, or ending in Count), referenced directly, and followed by an English noun we can singularize. {data?.count} items doesn't qualify, because if data is undefined you'd ship "NaN items" in nine languages.
When an element can't be folded into a sentence safely (a child expression with side effects, spread props, children passed through, literal braces in the text), it's kept as separate fragments and you get a warning instead of a guess.
Step 2: Keys That Stay Put
Key names are boring on purpose. The namespace comes from the file path, with noise like src, app, components and a trailing page or index stripped. The key is the first five words, camelCased. So "Welcome back, {name}" in src/app/dashboard/page.tsx becomes dashboard.welcomeBack.
Collisions get _2, _3 suffixes. On later runs, a string whose text hasn't changed gets its existing key back instead of a new copy, so a rerun never renames keys under you.
Step 3: A Lockfile, So Reruns Are Nearly Free
Localization isn't a one-off, because you keep shipping English. So every key's source text is hashed (SHA-256) into a lockfile, along with its translations per locale:
.babelize/local/babelize-lock.json
On the next run, keys fall into four groups:
- Unchanged: reused, nothing sent anywhere.
- New: translated.
- Changed source: the hash differs, so it's translated again.
- Missing a locale: you added Japanese, or the glossary changed, so only the gaps are translated.
Commit the lockfile with your code. Teammates and CI then get the same "already translated" view, and a rerun after a small feature only pays for the handful of strings you added.
Step 4: Translate, Then Don't Trust the Output
Keys go to the model in batches of 15, three batches at a time. You can use your own key (Anthropic, OpenAI, Gemini, Groq, OpenRouter) or Babelize's hosted translation. Before sending, placeholders, tags and brand names are swapped for opaque tokens like [#VAR_0#], and the prompt says each must come back exactly once. ICU messages keep their argument names, #, and the target language's own plural categories.
Then every value is checked before it's written:
-
Placeholder lost: a token is missing or duplicated, so
{name}would never render. - Invalid ICU: the plural or select no longer parses, or a branch was dropped.
-
Markup mismatch: the
<link>tag disappeared from a branch. - Untranslated: the model handed the English back.
A rejected key gets one targeted repair attempt, with a note telling the model what was wrong. If that fails too, the key is left out of the locale file. It's never filled with English and never shipped broken. It's also kept out of the lockfile, so the next run retries it, and the CLI exits with code 6 so CI notices a partial run.
Step 5: Rewrite the Code Without Wrecking Your Diff
Injection reuses the exact source positions recorded during extraction, and rewrites files with recast, so your formatting, comments and quote style survive. For Next.js App Router projects with next-intl:
- A component that's already
asyncand isn't in a'use client'file getsawait getTranslations(). Everything else getsuseTranslations(). Components are never made async behind your back. - Rich text becomes
t.rich()with your original element passed back in, so the link keeps itshrefand props.
const t = useTranslations();
<h1>{t("dashboard.welcomeBack", { name: user.name })}</h1>
<p>
{t.rich("dashboard.readTheTermsBeforeYou", {
link: (chunks) => <Link href="/terms">{chunks}</Link>,
})}
</p>
Then the project is wired up:
-
i18n/request.tspicks the language from aNEXT_LOCALEcookie, then the browser'sAccept-Language. -
next.configis wrapped with next-intl's plugin, and the root layout's children withNextIntlClientProvider. - Messages go to
public/locales/<locale>.json. - No middleware and no
/de/...routes, so your URLs, auth and redirects stay untouched.
On local runs, your own Prettier or Biome config then runs on the changed source files, so the diff reads like a teammate wrote it.
What It Refuses to Guess
The interesting part of a tool like this is where it stops. Babelize reports these instead of improvising:
- Module-level constants used outside a render: translating them at import time would freeze one language. You get "move it inside the component".
- Strings with no place for a hook, outside any component: extracted and translated, but listed as not wired in, with the reason.
-
Dynamic keys like
t(`status.${s}`): once one appears, the "unused keys" report turns itself off rather than flagging keys that are in use. - A run that would delete existing translations aborts.
-
Hardcoded dates and currency get a warning, because formatting them belongs to
Intl, not to translators.
Local Diff or Pull Request
There are two ways to run the same pipeline:
npx babelize
This runs in your project folder. It needs a git repo and a clean working tree, works on a temporary copy of the project, and only writes back if none of your files changed in the meantime. You review with git diff and commit, lockfile included. With your own LLM key in the environment you don't need an account.
Or connect the repo with the GitHub App. The same pipeline then opens a pull request, and can keep one PR per branch up to date as you push.
Either way the rule is the same: nothing lands in your code until a human reviews it. Localization tools that write straight to production are how you end up with "Willkommen zurück, undefined".
Written with AI assistance and reviewed by the Babelize team. Every technical detail was checked against the Babelize engine source.
Originally published on the Babelize blog. Disclosure: Babelize is our product, an AI localization tool for Next.js, React and Vue apps: it turns hardcoded strings into a reviewed i18n pull request.
來源:DEV Community · dev.to