Most i18n setups ask you to name a string before you have finished writing it.
You invent pricing.hero.title, open a JSON file, wire a lookup, then finally type the sentence. Multiply that by a few thousand UI strings and naming becomes the work.
I got tired of that, so I built the opposite habit into the toolchain:
Write the sentence where it belongs. Wrap it in double brackets. Let the tool derive the ID.
That tool is bracket-i18n. This post is a short record of the idea — not a feature tour.
The loop
You write this:
export default function Pricing({ locale }: { locale: string }) {
return <h1>[[Choose a plan that grows with you]]</h1>;
}
After scan (or a save that runs the watch hook), it becomes:
import { Languages } from '@/i18n';
export default function Pricing({ locale }: { locale: string }) {
return <h1>{Languages('eb573cd25ff_', locale) /* Choose a plan… */}</h1>;
}
And the catalog gains an entry:
eb573cd25ff_: { en: `Choose a plan that grows with you`, /* EMPTY */ },
You never invent the key. The source string is the source.
One hard rule: the converted call uses the identifier locale literally. Destructure it in scope, or the file will not compile.
What the machine is allowed to do
fill can ask Gemini to draft the other locales you configured — the ones still marked /* EMPTY */.
That is optional. Shipping still goes through a person:
- CSV export / import for review
-
pendingfor “not ready yet” -
lock: truewhen a string is done
AI drafts. You decide what leaves the building.
Billing note: with Run on Save, a converting save can chain into
filland cost money. While you are mass-editing[[ ]]markers, pause the watcher (i18n:pause), finish the copy, thenresume→scan→ deliberatefill.
Install (minimal)
npm install -D bracket-i18n
npx bracket-i18n init
init writes a config file, a small runtime under src/i18n/, npm scripts, and (optionally) a save hook. Edit the locale list before the first fill. Put GEMINI_API_KEY in a root dotenv file only if you use fill.
Built-in fill uses Gemini on purpose: cost and accuracy are a reasonable default for large catalogs. Need another provider? Fork the fill engine, or skip fill and use CSV round-trip. That is clearer than half-supported multi-provider plugins.
Why TypeScript catalogs (not only JSON)
Entries live in src/i18n/data/part*.ts as template literals. Quotes and apostrophes do not need escaping. Plurals and gender branches are plain objects; plural categories come from Intl.PluralRules.
Markers can carry flags when you need them:
[[--n Steve has {count} dogs.]]
[[--g He posted.]]
[[--n --g She has {count} dogs.]]
The runtime stays one function — Languages(id, locale, opts?) — no provider tree required.
What this is not
- Not a zero-config toy. You own the locale list and the review step.
- Not “AI replaces translators.” Drafts are a first pass.
- Not a claim that every app should work this way. It is a workflow that stopped me from naming keys for a living.
I use it in production on a browser music product that ships several locales from one JSX habit. The interesting part was never the hash ID. It was removing the moment where copy and catalog drift apart.
Links
- npm: bracket-i18n
- Issues: creathree/bracket-i18n
If the [[ ]] habit fits your repo, try it on a small surface first — one page, one locale pair, one fill, then lock what you trust.
Top comments (0)