@happyvertical/smrt-languages
v0.36.0
Published
Code-first language strings with config + tenant overrides and AI auto-translation for SMRT
Maintainers
Readme
@happyvertical/smrt-languages
Code-first language strings with config + tenant overrides and AI-driven auto-translation for SMRT applications.
smrt-languages mirrors the architecture of @happyvertical/smrt-prompts:
packages declare their user-facing strings as code, applications and tenants
override them through file config or DB rows, and the resolver merges every
layer at runtime. v1 adds an automatic AI-translation step backed by
@happyvertical/smrt-jobs: the first time a string is requested in a locale
that has neither a code default nor an override, the resolver returns a
fallback locale immediately and enqueues a background translation. Subsequent
requests hit the new app-level row.
This package is a Phase 2 prerequisite of the broader package adoption epic — issue #1200.
Why
- Today, packages hard-code labels like
'Member','Article', or'Payment due by {dueDate}'. There is no way for a tenant to use'Subscriber'instead of'Member'without forking, and no way for a tenant to operate in their own language. smrt-promptsalready proved the layered-override pattern for AI prompts.smrt-languagesapplies the same pattern to display strings, plus an AI-driven gap-filler that closes the loop on missing locales without blocking on the first request.
Quick start
import {
defineLanguageString,
resolveLanguageString,
} from '@happyvertical/smrt-languages';
// Define defaults at startup, typically in your package's
// __smrt-register__.ts so the registry is populated before resolves run.
defineLanguageString({
key: 'users.role.member',
locale: 'en',
template: 'Member',
});
defineLanguageString({
key: 'commerce.invoice.dueText',
locale: 'en',
template: 'Payment due by {dueDate}',
});
// Resolve at runtime. Tenant context is read from AsyncLocalStorage by default.
const text = await resolveLanguageString('users.role.member', {
db,
locale: 'es',
vars: { dueDate: '2026-06-01' },
// strict: false → return the English fallback and enqueue an AI translation.
// strict: true → throw when no resolution exists.
});Resolution layers
In ascending priority:
- Code default —
defineLanguageString({ key, locale, template }) - File/config override —
getPackageConfig('languages').overrides[key][locale] - App-level stored override —
LanguageOverriderow withtenantId = null - Tenant-level stored override —
LanguageOverriderow withtenantId = <current> - Runtime override —
resolveLanguageString(key, { overrides: { template: '...' } })
When the requested (key, locale) doesn't exist anywhere, the resolver walks
a fallback chain — fr-CA → fr → registered default-locale (en) — and
returns the first hit. Whenever the hit is at a different locale than what
was requested, an AI translation job is enqueued for the original target.
Storage
Overrides live in _smrt_language_overrides:
| Column | Notes |
|--------|-------|
| key | Namespaced string key (users.role.member) |
| locale | BCP-47 tag (en, fr-CA) |
| tenantId | null for app-level, tenantId for tenant-level |
| template | The override string with {var} placeholders |
| auto_generated | true when produced by the AI translation job |
| source_hash | sha256 of the source template at translation time |
| ai_model | Model identifier; null for human-edited rows |
| reviewed_at / reviewed_by | Set when an admin approves an auto row |
Source-hash gating means re-translation only happens when the source actually
changes — auto-generated rows whose source_hash matches are left alone.
Human-edited rows (auto_generated: false) are never overwritten.
AI auto-translation
When a (key, targetLocale) is missed, the resolver enqueues a
LanguageTranslationTask job into the languages queue with a deterministic
dedup ID — smrt-languages.translate:<key>:<targetLocale> — so concurrent
misses collapse into a single job. The job:
- Reads the tenant's existing language overrides as a glossary (no-op when no tenant context).
- Calls
@happyvertical/aiwith a low-temperature translation prompt that itself is registered viasmrt-promptsundersmrt-languages.translation— operators can tune the wording without redeploying. - Validates the response (non-empty, no obvious markup leaks).
- Upserts an app-level
LanguageOverriderow withauto_generated: true,source_hash, andai_model. - Invalidates the resolver cache for
(key, targetLocale, *).
Cost & abuse controls
smrt-featuresflagsmrt-languages.auto_translate— global / per-tenant kill switch.translationBudgetPerTenantPerDay— daily cap per tenant.supportedLocales— optional allowlist; jobs for other locales are dropped before any AI call.- Source-hash gating prevents re-translation when nothing changed.
Admin review
smrt languages translate --locales=es,fr,de # batch eager pre-population
smrt languages review --locale=es # list unreviewed auto rows
smrt languages approve <id> # mark reviewed
smrt languages edit <id> --template "..." # edit + flip auto_generated to falseCLI surfaces are auto-generated by SMRT from the LanguageOverride model and
helpers in src/cli.ts.
Configuration
smrt.config.{js,ts,json}:
export default {
packages: {
languages: {
defaultLocale: 'en',
supportedLocales: ['en', 'es', 'fr', 'de', 'ja'],
translationBudgetPerTenantPerDay: 200,
overrides: {
'users.role.member': {
es: 'Miembro',
},
},
},
},
};Subpath exports
The package root (@happyvertical/smrt-languages) only exposes the read path:
defineLanguageString, resolveLanguageString, LanguageOverride, the cache
helpers, the glossary helper, and shared types/utilities. Loading the root
does not pull @happyvertical/ai, smrt-jobs, smrt-features, or
smrt-prompts into the consumer bundle.
The translation-worker stack lives on a subpath:
// Background workers + tests that enqueue or run translation jobs.
import {
enqueueTranslationJob,
LanguageTranslationTask,
AUTO_TRANSLATE_FEATURE_KEY,
TRANSLATION_PROMPT_KEY,
} from '@happyvertical/smrt-languages/jobs';
// Admin / batch CLI helpers.
import {
translateMissing,
approveAutoTranslation,
editLanguageOverride,
listUnreviewedAutoTranslations,
} from '@happyvertical/smrt-languages/cli';The resolver itself dynamically imports the worker module on a soft-miss, so
calling resolveLanguageString still triggers a translation enqueue without
the caller having to pre-import the /jobs subpath.
Out of scope (v1)
Pluralization, ICU MessageFormat, Svelte component i18n, RTL layout, locale
negotiation HTTP middleware, XLIFF/PO TM-tool integration, and richer quality
scoring of AI translations are all v1.1+ concerns. v1 sticks to plain
{var} substitution and the resolution chain above so adoption stays a
mechanical refactor across consumer packages.
