Skip to main content

@happyvertical/smrt-languages

Code-first language strings with config + tenant overrides and AI-driven auto-translation for s-m-r-t 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-prompts already proved the layered-override pattern for AI prompts. smrt-languages applies 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:

  1. Code defaultdefineLanguageString({ key, locale, template })
  2. File/config overridegetPackageConfig('languages').overrides[key][locale]
  3. App-level stored overrideLanguageOverride row with tenantId = null
  4. Tenant-level stored overrideLanguageOverride row with tenantId = <current>
  5. Runtime overrideresolveLanguageString(key, { overrides: { template: '...' } })

When the requested (key, locale) doesn't exist anywhere, the resolver walks a fallback chain — fr-CAfr → 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:

ColumnNotes
keyNamespaced string key (users.role.member)
localeBCP-47 tag (en, fr-CA)
tenantIdnull for app-level, tenantId for tenant-level
templateThe override string with {var} placeholders
auto_generatedtrue when produced by the AI translation job
source_hashsha256 of the source template at translation time
ai_modelModel identifier; null for human-edited rows
reviewed_at / reviewed_bySet 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:

  1. Reads the tenant's existing language overrides as a glossary (no-op when no tenant context).
  2. Calls @happyvertical/ai with a low-temperature translation prompt that itself is registered via smrt-prompts under smrt-languages.translation — operators can tune the wording without redeploying.
  3. Validates the response (non-empty, no obvious markup leaks).
  4. Upserts an app-level LanguageOverride row with auto_generated: true, source_hash, and ai_model.
  5. Invalidates the resolver cache for (key, targetLocale, *).

Cost & abuse controls

  • smrt-features flag smrt-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 false

CLI surfaces are auto-generated by s-m-r-t 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.