@verbaly/compiler
v0.64.0
Published
Message extraction and type-safe codegen for Verbaly.
Downloads
4,920
Maintainers
Readme
The compiler behind Verbaly: AST extraction of t`…` and JSX <Trans> children into stable hashed keys (or readable keys via t.id('inbox.title')`…` and <Trans id="inbox.title">…</Trans>), JSON catalog sync, and typed codegen. It also ships the verbaly CLI.
Extraction covers .js/.ts/.jsx/.tsx and .svelte, .vue and .astro single-file components: script blocks and frontmatter, plus markup expressions (Svelte's $t store form included). Text that only sits on screen is never extracted, so a documented snippet cannot become a real key.
Most projects don't install this directly:
@verbaly/vitewraps it with zero config. Reach for it when scripting extraction/checks yourself.
🧰 CLI
npx verbaly init # scaffold config + locale catalogs (detects your framework)
npx verbaly doctor # diagnose the setup (config, catalogs, wiring, types, translations)
npx verbaly wrap # onboarding codemod: report plain JSX text, --write wraps it in t``
npx verbaly migrate # port catalogs from another i18n library (--write applies, --plurals merges)
npx verbaly extract # sync catalogs + types
npx verbaly extract --watch # keep extracting as you code (dev loop)
npx verbaly extract --prune # drop orphaned keys
npx verbaly status # coverage per locale, plus unreviewed and broken counts
npx verbaly check # exit 1 if anything is missing or broken (CI)
npx verbaly translate # fill missing translations via Claude (or your provider), as drafts
npx verbaly review # list machine drafts, --approve accepts them
npx verbaly-studio # the same catalogs on localhost (@verbaly/studio, separate package)
npx verbaly export # translator files (XLIFF 2.0, CSV, gettext PO) or mobile resources (Android, iOS)
npx verbaly import <files> # fill catalogs back from translated XLIFF/CSV/PO files
npx verbaly pseudo # generate a pseudo-locale catalog for i18n QA (en-XA)
npx verbaly render # pre-fill data-verbaly HTML per locale (SSG, kills the FOUC)Reads verbaly.config.{js,mjs,ts,mts,json} (TS configs need esbuild installed). Generates locales/<locale>.json (portable JSON, flat or nested, whichever the file already is) and verbaly.d.ts with params typed per key.
🚦 The build gate
verbaly check is the only command that exits 1, and it asks two questions, not one.
Is every message translated? A missing key or an empty value fails the build, so raw keys never reach production.
Can the translation render what the source renders? Presence is not correctness. These fail too, each with the reason in plain words:
| The translation… | Why it fails |
| --------------------------------------- | ---------------------------------------------- |
| lost a {param}, or renamed it | the value never reaches the text |
| lost an <em> (or gained one) | the emphasis, code or link marker is gone |
| turned a plural block into plain text | one form for every count |
| has a plural block with no other case | every count it does not list renders empty |
Two more are reported as warnings and keep the exit code at 0, because the text still renders: a plural set missing forms the target language needs (Polish or Arabic want more than English), and a dropped =0 style case that now falls back to other.
npx verbaly check # text report
npx verbaly check --reporter github # ::error and ::warning annotations on the PR, at the source line
npx verbaly check --drafts # also fail while machine translations await reviewHand-edited catalogs get the same treatment as imported files: the gate does not care where a translation came from.
🤖 Machine translation
verbaly translate fills the "" holes check reports. The default provider uses Claude via the official SDK; install it as a dev dependency (translation is a build-time step, not an app runtime dependency): pnpm add -D @anthropic-ai/sdk (or npm i -D), plus ANTHROPIC_API_KEY. Default model is claude-sonnet-5 (balanced quality/cost); override with translate.model in config or --model <id>. Placeholders, variants and tags are validated after translation: anything not preserved verbatim stays "" so check keeps failing.
Long runs survive the network. Batches go out in parallel (translate.concurrency, default 4), a transient failure is retried (translate.retries, default 2), and a batch that still does not answer is reported with its keys while every batch that did answer is written: re-running asks only for what is left.
Two config options steer the wording. translate.instructions is free text appended to the system prompt (tone, address form, product voice), and translate.glossary states how a term has to come out, for all locales or per locale, so a brand name never comes back translated:
export default {
sourceLocale: 'en',
locales: ['en', 'es', 'pt'],
translate: {
instructions: 'Address the reader informally. Keep UI labels short.',
glossary: { Verbaly: 'Verbaly', checkout: { es: 'pago', pt: 'pagamento' } },
},
};Only the terms a batch actually contains are sent, so a glossary of hundreds never becomes the prompt.
Machine output is a draft until a human says yes. Everything translate writes is recorded in locales/.verbaly-drafts.json (commit it, never edit it by hand). verbaly review lists the drafts, --approve accepts them, and importing a translator's file clears the flag because a human already reviewed it. verbaly check --drafts turns "nothing unreviewed ships" into a CI rule; plain check leaves it alone, since a draft has a value and is therefore not missing.
Plug your own provider in verbaly.config.ts. In TypeScript, TranslateProvider types it for you:
import type { TranslateProvider } from '@verbaly/compiler';
const provider: TranslateProvider = async ({ targetLocale, messages, origins, glossary }) => {
// origins maps a key to the source files it appears in, so you can translate with context
// glossary and instructions ride along too: a custom provider gets the same steering
return { ...translated };
};
export default { sourceLocale: 'en', locales: ['en', 'es'], translate: { provider } };🔗 Where the language lives in the URL
One setting, because every other URL answer follows from it:
You usually write nothing. The mode follows your setup: a project with a render section builds one URL tree per language, so it is prefix-except-source; a project without one has a single address, so it is no-prefix. Say it out loud only to disagree:
export default {
sourceLocale: 'en',
locales: ['en', 'es', 'pt'],
routing: 'prefix-all', // /en/docs and /es/docs, when no language is the house language
};| routing | The address | Switching language is |
| ---------------------- | ------------------------- | ----------------------------- |
| prefix-except-source | /docs and /es/docs | a navigation |
| prefix-all | /en/docs and /es/docs | a navigation |
| no-prefix | /docs in every language | the text changing where it is |
Pick by surface, not by taste: Google recommends a different URL per language rather than cookies or browser settings, so anything people reach through search wants the language in the address. An app behind a login loses nothing with no-prefix.
virtual:verbaly exports routing, localePath, localeFromPath and switchLocale, already bound to your locales, source and mode. The switcher is one line and it is the same line in every mode:
import { switchLocale } from 'virtual:verbaly';
await switchLocale('es');Under a prefix mode that goes to /es/…, which is already rendered in Spanish, so there is no catalog to fetch and no flash. Under no-prefix it swaps the text where it stands and the address never changes. Either way it remembers the choice, in the cookie a server reads and in the storage the pre-paint redirect reads, and it sets <html lang> and <html dir>. Pass your framework's router so the app survives the switch:
await switchLocale('es', { navigate: (path) => router.push(path) });npx verbaly doctor names the mode you are in, and says so when no-prefix sits next to a render section that writes one URL tree per locale.
🧪 What the runtime carries, and what it does not
Verbaly's own syntax covers plurals, selects and formats, and ICU message syntax is the escape hatch for what it does not. You pay for the escape hatch only if you open it. The compiler reads your catalogs, and if any message uses ICU it wires the parser into the generated runtime; if none does, the parser is not in your bundle at all. Measured on a real app bundle: 544 bytes gzip, which is 15% of the runtime.
Relative time works the same way. {when:relative} and {n:relative/day} pull Intl.RelativeTimeFormat and a unit table, another 318 bytes, and most apps never write one. Same deal: the compiler sees it in your catalogs and wires it, or it is not in your bundle.
Nothing to configure. The one case the catalogs cannot answer is a message that arrives after the build, from a CMS or a fetched catalog:
export default { locales: ['en', 'es'], icu: true, relative: true }; // ship them anywayWithout that, such a message renders its own source text (ICU) or the raw value with a warning that names what is missing (relative), rather than half-rendering into something that looks plausible.
The other format cases stay in the runtime always, and that is measured rather than assumed: currency, date, time, unit, list, percent and integer cost 24 to 38 bytes each, so making them optional would buy less than the machinery to do it.
🌍 Human translators & TMS
Catalogs are plain JSON, in whichever shape your file already has: most TMS platforms (Crowdin, Lokalise, Phrase, …) ingest them natively; point the platform at locales/ and you're done. For everything else there's a built-in round-trip:
npx verbaly export # verbaly-export/<locale>.xlf (XLIFF 2.0, source + target per unit)
npx verbaly export --format csv # spreadsheet-friendly: key,source,target,location
npx verbaly export --format po # gettext PO (msgctxt = key, works with any PO editor)
npx verbaly import verbaly-export/es.xlf # fill the catalog backexport writes one file per target locale with the source text alongside the current translation (--missing exports only the untranslated entries). Every entry carries where the text lives in your source (XLIFF location notes, a location column in CSV, #: comments in PO), so translators and TMS tools see the context instead of guessing it. In XLIFF, {params} and rich tags travel as protected inline codes with semantic ids (<ph id="name"/>, <pc id="em">), so TMS editors show them as untouchable chips instead of editable raw syntax. import reads XLIFF 2.0/1.2, CSV or PO back (PO entries flagged fuzzy count as untranslated) and validates every entry like translate does: a translation that drops a {param}, a variant block or an <em> tag is rejected and reported, so a translator's typo can't break your UI. Existing translations are kept unless --overwrite; --dry-run previews everything.
📱 Mobile resources
The same catalogs can ship to a companion mobile app as drop-in native resources:
npx verbaly export --format android-xml # verbaly-export/values-<locale>/strings.xml (drop into res/)
npx verbaly export --format ios-strings # verbaly-export/<locale>.lproj/Localizable.strings (drop into Xcode)Your source locale becomes the platform default (values/strings.xml, en.lproj), and untranslated keys are skipped so the app falls back to it natively instead of showing empty text. Keys are sanitized to valid Android resource names (hero.title → hero_title; a collision fails loudly), values keep Verbaly's {name} syntax. Export-only by design: translations flow from your catalogs to the app.
📄 Static rendering (SSG)
verbaly render walks your built site (dist/ by default, --site <path> to change) and pre-fills every data-verbaly element per locale using the real runtime: plurals, Intl formatting, data-verbaly-args, attribute translation and data-verbaly-rich (same whitelist, XSS-safe). The source locale is filled in place; every other locale is mirrored to dist/<locale>/… with <html lang> set. Static HTML ships already translated (no flash of untranslated content) and the runtime attributes stay put, so client-side locale switching keeps working.
Named links in rich messages render as real <a> elements; hrefs come from config or markup, never from messages (javascript: blocked):
// verbaly.config.ts
render: { links: { docs: { href: '/docs', target: '_blank', rel: 'noopener' } } }Per-element data-verbaly-links='{"repo":"https://…"}' merges over the config map.
The head is half of what a search result shows. A mirrored page whose <title> and <meta name="description"> are not bound ships the source language to every locale, which is most of the reason to give a locale its own URL in the first place. Bind them like anything else, and render fills them:
<title data-verbaly="page.title">URL strategy</title>
<meta name="description" content="…" data-verbaly-attr='{"content":"page.desc"}' />
<meta property="og:title" content="…" data-verbaly-attr='{"content":"page.title"}' />verbaly render counts the pages whose title never varies and says so, once, with the fix. It is a warning and never fails a build: a site can have a name that does not translate.
Multi-locale SEO: set render.baseUrl (or --base-url) and every page gets reciprocal <link rel="alternate" hreflang> (plus x-default) for the whole locale set; --sitemap writes a locale-aware sitemap-i18n.xml. --clean drops stale dist/<locale>/ pages before mirroring. Injection is idempotent.
Text that only one page needs
A catalog only grows, and every page downloads all of it. A changelog, a blog archive or a long FAQ makes every visitor pay for text they are not looking at. Name those groups and they stay in the build:
export default {
locales: ['en', 'es'],
bundle: { exclude: ['changelog'] }, // still pre-filled by render, no longer downloaded
render: { baseUrl: 'https://example.com' },
};render keeps reading the full catalogs, so those pages still publish translated; the browser just stops fetching that text. Entries are key prefixes matched whole segment by whole segment, so nav never takes navbar with it, and changelog.v1 excludes one version instead of the group. Measured on this project's own docs site: the locale chunk goes from 55.6 KB to 39.3 KB brotli with nothing lost on screen.
It composes with render.inlineCatalog, it is not replaced by it. With both on, a mirrored page ships only the messages it renders (2.86 KB brotli on that site's home) and fetches no chunk at all, while the exclusion caps what a visitor would download on the day something does need the catalog.
Use it for text render covers. A key that is excluded and then asked for at runtime resolves to itself, like any absent key, and bindDom leaves the pre-rendered text alone rather than painting the key over it. The build says so when a prefix matches nothing, and when your code reads an excluded key through t(). To use an excluded group at runtime anyway, load it yourself:
import { verbaly } from 'virtual:verbaly';
const extra = await import(`./i18n/changelog/${locale}.json`);
verbaly.addMessages(locale, extra.default);🔍 Pseudo-localization
verbaly pseudo fills a QA catalog (en-XA by default, --locale <id> to change) from the source: accented letters, ⟦…⟧ markers and ~33% length padding reveal hardcoded strings, clipped layouts and concatenation bugs. Params, variant blocks and tags survive verbatim, with the same structural validation as translate.
📖 Docs: https://verbaly-web.vercel.app/docs/reference/cli
⚠️ Early development (
0.x): API not stable yet.
🧩 Programmatic API
Almost nobody needs this: the CLI and the framework plugins cover the whole cycle. It exists for the one case they do not, building your own integration for a bundler or a tool we do not ship.
The package exports two layers, and nothing else is public. Anything you can see in the source but not in this list is internal and can change in any release.
Your project's own types, for a typed config file or a custom provider:
VerbalyConfig · ResolvedConfig · RenderConfig · RedirectConfig · BundleConfig · TranslateConfig · GlossaryEntry · TranslateProvider · TranslateRequest · TranslateResult · TranslateOptions · TranslateProgress · TranslateFailure
Building an integration. This is exactly what @verbaly/vite, @verbaly/unplugin, @verbaly/next, @verbaly/astro, @verbaly/nuxt, @verbaly/mcp and @verbaly/studio consume, so a third one has everything they have:
| Area | Exports |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Setup | init · types Host InitOptions InitResult |
| Config & catalogs | loadConfig resolveConfig targetLocales loadCatalogs readCatalog parseCatalog writeCatalog clientCatalogs needsIcu needsRelative · types Catalog Catalogs |
| Extraction | extractProject collectOrigins syncCatalogs pruneCatalogs MessageRegistry stableKey · type SyncResult |
| Codegen | generateDts writeDts generateRuntimeModule generateLocaleModule · types DtsOptions RuntimeModuleOptions |
| Bundler plumbing | transformSource transformCode runBuildGate createSourceFilter isTransformTarget resolveVirtualId loadVirtualModule RESOLVED_VIRTUAL_ID LOCALE_MODULE_PREFIX SOURCE_FILE_RE · types PluginOptions TransformResult |
| The gate | check validateMessage validatePair formatCheckResult formatCheckWarnings · types CheckResult MissingEntry UnknownEntry BrokenEntry StructureIssue IssueSeverity |
| Coverage | status formatStatusResult counted · types StatusResult LocaleStatus |
| Draft review | loadDrafts saveDrafts markDrafts clearDrafts effectiveDrafts DRAFTS_FILE · type Drafts |
| Translation | translateCatalogs resolveProvider formatTranslateFailures |
| Diagnosis | doctor formatDoctorEntry · types DoctorResult DoctorEntry |
| Onboarding | wrapProject · types WrapResult WrapEntry WrapSkip WrapBlocked WrapOptions |
| Static rendering | renderSite formatRenderWarnings · types RenderSiteOptions RenderSiteResult |
| Error output | formatCliError |
A minimal plugin is loadConfig + loadCatalogs once, then transformSource per file and runBuildGate at the end:
import {
MessageRegistry,
loadCatalogs,
loadConfig,
runBuildGate,
transformSource,
} from '@verbaly/compiler';
const cfg = await loadConfig(process.cwd());
const catalogs = loadCatalogs(cfg);
const registry = new MessageRegistry();
// per file: rewrites t`…` to a keyed call and hands you the messages it found
const { messages, result } = transformSource(code, id, registry);
// at the end of the build: throws with the reason and the remedy
runBuildGate(cfg, registry);License
MIT © Aron Soto
