hazo_i18n
v0.15.3
Published
Catalog-management and governance layer for i18n across the hazo_* workspace — wraps next-intl, compiled artifact snapshots, DB authoring, locale detection, and SEO feeds.
Maintainers
Readme
hazo_i18n
Catalog-management and governance layer for i18n across the hazo_* workspace. hazo_i18n is
not a message-formatting engine — it wraps next-intl (an optional
peer) and keeps the ICU message format the workspace has already standardised on. What it adds on
top:
- Cross-package catalog registry — every
hazo_*package can register its own bundled English strings under its own namespace; a consuming app can override or add locales without touching package internals. - Compiled artifacts, not live DB reads — translations are authored in a database (optional,
via
hazo_connect) but the runtime read path is a frozen, pre-compiled per-locale JSON snapshot. A request never touches the database for a translation lookup. - Export → LLM → import workflow with SHA-256 source fingerprints, so an English string edit
automatically flips its translations to
staleand puts them back in the next export. - Locale detection with a fixed precedence chain (URL > cookie > Accept-Language > geo > default) that never auto-redirects — geo-IP is a suggestion, never a decision.
- SEO data feeds — hreflang alternates, per-locale sitemap entries, and translation-completeness
state (
full/partial/none) for excluding thin machine-translated pages from indexing.
hazo_i18n is headless — it ships zero React components. The <LocalePicker> and
<LocaleSuggestionBanner> live in hazo_ui; the translations admin panel lives in hazo_admin.
Status: Phases 1–3 complete. The package includes the full catalog-management and governance
layer with real APIs: core runtime (Phases 1–2), DB authoring with export/import/publish (Phase 3),
and SEO data feeds (FR-032). Consumer-facing translation admin UI lives in hazo_admin
(FR-029/FR-030, out of scope here). See design/PRD-hazo_i18n.md for the full design and
design/master_plan.md for phase tracking.
Installation
npm install hazo_i18nhazo_core is a required peer dependency. Everything else — hazo_connect (DB authoring),
hazo_state (logged-in locale persistence), next-intl (message runtime), next, and react —
is an optional peer. hazo_i18n degrades gracefully when any of them is absent (see G1 below).
Export paths
| Path | Purpose |
|---|---|
| hazo_i18n | Server entry — createI18nRuntime, getTranslations, getLocale, getLocaleFallbackChain (sync, no DB), registerCatalog, createPackageT, DB authoring (createMessageService, exportMessages, importMessages), artifact building |
| hazo_i18n/client | 'use client' — useTranslations, useLocale, useSetLocale, HazoI18nProvider |
| hazo_i18n/format | Memoised Intl.* wrappers — browser-safe, no Node APIs |
| hazo_i18n/seo | buildAlternates, localeSitemapEntries, translationState (integration with hazo_seo) |
| hazo_i18n/api | createI18nRoutes — REST endpoints for message CRUD, coverage reports, export/import/publish workflows |
| hazo_i18n/ui | 'use client' — <TranslationsAdminPanel>, useTranslationsAdmin, <CoverageDashboard>, <MessageGrid>, <TranslateDialog>, <TranslationsPage> (see "Admin UI & AI translate" below), <LocaleSwitcher>, <TranslateNavItem> (see "Sidebar components" below) |
| hazo_i18n/setup | ensureI18nTables, seedLocalesFromConfig — idempotent DB-authoring-store table creation + locale seeding, standalone or via createI18nRoutes({ ensureTables, seedLocales }) (see below) |
Real API examples
Server: Initialize runtime and read translations
// app.ts (server setup, once per app startup)
import { createI18nRuntime } from 'hazo_i18n';
import { createHazoConnect } from 'hazo_connect';
const connect = await createHazoConnect({ /* ... */ });
await createI18nRuntime({
config: 'hazo_i18n_config.ini',
getHazoConnect: () => connect,
// optional: includePackageCatalogs to load registered hazo_* package catalogs
});// middleware.ts or page.tsx (request handling)
import { getTranslations, getLocale } from 'hazo_i18n';
export default async function Page() {
const locale = await getLocale(); // e.g. "de" from URL/cookie/Accept-Language
const t = await getTranslations(locale); // returns translated catalog for locale
return <h1>{t('hazo_auth.login.greeting')}</h1>;
}Client: Use hooks and provider
// layout.tsx (root)
import { HazoI18nProvider } from 'hazo_i18n/client';
export default function Layout({ children, params }) {
return (
<html lang={params.locale}>
<body>
<HazoI18nProvider locale={params.locale}>
{children}
</HazoI18nProvider>
</body>
</html>
);
}// components/locale_picker.tsx
'use client';
import { useLocale, useSetLocale } from 'hazo_i18n/client';
export function LocalePicker() {
const locale = useLocale();
const setLocale = useSetLocale();
return (
<select value={locale} onChange={(e) => setLocale(e.target.value)}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
);
}API: Message CRUD and publish workflow
// api/i18n/route.ts
import { createI18nRoutes } from 'hazo_i18n/api';
import { createHazoConnect } from 'hazo_connect';
const connect = await createHazoConnect({ /* ... */ });
const i18nRoutes = createI18nRoutes({ getHazoConnect: () => connect });
export const GET = i18nRoutes.GET; // ?action=coverage|messages|unpublished
export const POST = i18nRoutes.POST; // ?action=update|delete|export|import|publishZero-config DB setup: skip the manual migration step
createI18nRoutes({ ensureTables: true, seedLocales: true }) lazily creates the three
DB-authoring-store tables (hazo_i18n_locales/hazo_i18n_messages/hazo_i18n_publications) and
upserts one hazo_i18n_locales row per loadI18nConfig().locales.enabled code, on the first
GET/POST this route handler receives — once per process, memoized. This replaces the manual
psql -f migrations/001_hazo_i18n_tables.sql step in SETUP_CHECKLIST.md for local/dev setup:
// api/i18n/route.ts
const i18nRoutes = createI18nRoutes({ getHazoConnect: () => connect, ensureTables: true, seedLocales: true });Or call the same two functions explicitly from hazo_i18n/setup (e.g. a boot script or Next.js
instrumentation hook) instead of piggybacking on the first request:
import { ensureI18nTables, seedLocalesFromConfig } from 'hazo_i18n/setup';
await ensureI18nTables(() => connect);
await seedLocalesFromConfig(() => connect);Both throw HazoUnavailableError for a PostgREST-backed adapter (no raw-SQL execution surface) —
fall back to the manual migration step in that case.
Zero-setup fallback: Package authors use createPackageT for built-in strings
// hazo_auth/src/index.ts — works with or without hazo_i18n setup
import { createPackageT } from 'hazo_i18n';
const messages = { en: { login: 'Sign in', greeting: 'Welcome, {name}!' } };
const t = createPackageT('hazo_auth', messages.en);
export function LoginButton() {
return <button>{t('login')}</button>; // "Sign in" in English, or translated if hazo_i18n runtime exists
}The zero-setup contract (G1)
The single most important cross-package contract: any hazo_* package can ship user-facing
strings and have them work with zero setup in a consuming app that hasn't installed or
configured hazo_i18n at all.
// inside hazo_auth, hazo_notify, etc.
import { createPackageT } from 'hazo_i18n';
const en = {
'login.submit': 'Sign in',
'login.greeting': 'Welcome back, {{name}}!',
};
const t = createPackageT('hazo_auth', en);
t('login.submit'); // "Sign in"
t('login.greeting', { name: 'Amy' }); // "Welcome back, Amy!"- If
hazo_i18n's runtime singleton isn't registered (the consuming app never calledcreateI18nRuntime),createPackageTfalls back to simple{{var}}mustache-style interpolation directly against the bundled English object — nonext-intl, no database, no config file required. - If the runtime is registered,
createPackageTresolves through the merged catalog registry instead, so an app that has set uphazo_i18ngets translated strings, locale fallback, and staleness tracking for the same call site. - Adoption is therefore never a breaking change — a package can start using
createPackageTtoday and nothing changes for apps that haven't opted into i18n yet.
Admin UI & AI translate
hazo_i18n/ui is an optional, 'use client' React surface over the ./api write endpoints —
drop <TranslationsAdminPanel> into any admin page instead of hand-building a message grid:
import { TranslationsAdminPanel } from 'hazo_i18n/ui';
<TranslationsAdminPanel fetchFn={fetch} basePath="/api/i18n" />Requires the optional peers hazo_ui (+ its own required peer hazo_theme) and lucide-react
for the components, and hazo_connect on the API side (same DB-authoring requirement as the rest
of ./api). None of these are required for the runtime read path — only for mounting this panel.
The panel's "Translate with AI" action calls createI18nRoutes()'s new ?action=translate
endpoint, which drives translateMessages() (./api's translateMessages export): it exports the
requested locale's chunks, sends each chunk through hazo_llm_api's hazo_llm_text_text(), and
imports whatever comes back through the same importMessages() ICU/HTML/expansion validation
gate a human- or pasted-in translation goes through — an LLM mistake is rejected there, not
bypassed. hazo_llm_api is also an optional peer, probed lazily; without it installed (or without
the consuming app having called its own initialize_llm_api() first) the translate action fails
per-chunk rather than throwing, and nothing else in this package is affected.
"All Languages" tab: translate → review → approve → publish, every locale at once
<TranslationsAdminPanel> has an "All Languages" tab (<AllLanguagesDashboard>, also exported from
hazo_i18n/ui, driven by useAllLanguagesWorkflow()). It shows per-locale stats
(missing / one column per status / coverage %) and runs the pipeline sequentially per locale:
- Translate Outstanding —
?action=translatewith scope'missing+draft+fix+stale'. - Review —
?action=review(reviewMessages()): an AI quality pass over'machine'rows. Passing rows become'reviewed'; failing rows become the new'fix'status (excluded from publish) with the reviewer's explanation stored inhazo_i18n_messages.review_reason. - Approve, then Publish once for all locales.
A locale whose translate step has any failed LLM chunk (e.g. missing API key) is now marked error
with the first failure's message instead of showing "0 added". When a 'fix' row is re-translated,
the reviewer's review_reason is sent to the LLM as a note so the rewrite addresses the flagged problem.
One locale failing never blocks the others or the final publish. Existing DBs need
migrations/003_add_review_reason.sql (adds the review_reason column; 002 is docs-only).
translateAndPublish(): bulk translate + publish from a script
translateAndPublish(opts) (hazo_i18n/api) wires the whole non-UI flow: ensureTables →
seedLocales → registerCatalog() per catalogs entry → initializeLlm() → one
translateMessages() per locale (sequential) → a single triggerPublish().
import { translateAndPublish } from 'hazo_i18n/api';
const { translate_results, publish_result } = await translateAndPublish({
getHazoConnect: () => connect,
ensureTables: true,
seedLocales: true,
initializeLlm: () => initialize_llm_api(),
// locales: ['de', 'sv'], // default: config locales.enabled minus locales.default
// scope: 'missing', // default
});localeHref() / useLocaleHref(): locale-prefixed links
localeHref(path, locale, opts) (server, hazo_i18n) and useLocaleHref() (client,
hazo_i18n/client) return root-relative, <a href>-ready paths using the same prefixing rule as
the SEO feeds: default locale unprefixed, others /<locale>/path.
Locale management: add/delete a locale from the admin panel
The panel's locale selector includes an "Add Language" button (<AddLocaleDialog>), backed by
?action=upsert-locale / ?action=delete-locale — no migration or config edit needed to add a
locale at runtime. Deleting a locale cascades: every hazo_i18n_messages row authored for it is
removed first, then the hazo_i18n_locales row itself; the source locale can't be deleted. Consumers
that just want a locale list (e.g. for their own sidebar <LocaleSwitcher>) without the full admin
panel can use the standalone useI18nLocales({ apiBase }) hook (hazo_i18n/ui) instead.
Adding a locale (consuming-app checklist)
Adding a locale an app serves touches two layers: the registry (what getLocales() /
isEnabledLocale() / server components read) and each package's own message bundles (what
actually gets translated). Steps 1 and 4 below are one-time wiring — done once per app,
before its first locale is ever added. Steps 2 and 3 are per-locale — repeated every time a
new locale is added.
- One-time: add a
LocaleDefto the app'screateI18nRuntime({ locales: [...] })call for every locale it serves, including ones added later — the registry is populated once, at startup, from that one list (seeregisterLocales()'s own doc comment). - Per-locale: create
src/messages/<code>.jsonfor each package/app bundle that ships its own translations. - Per-locale: add
<code>: () => import('./messages/<code>.json')to eachMESSAGE_LOADERSmap that loads those bundles. - One-time: pass
locales={getLocales()}from a server layout to<HazoI18nProvider>, so client components can calluseLocales()instead of hardcoding a locale array.
Why MESSAGE_LOADERS can't be centralized: bundlers (webpack, Turbopack) resolve dynamic
import() specifiers via static analysis at build time — they can only split a bundle per locale
when they see a literal import('./messages/<code>.json') call for each code, not a path built
from a runtime variable (import(\./messages/${code}.json`)is NOT statically analyzable, andimport(getLocales().map(...))even less so). This is why every consuming package/app keeps its
own explicitMESSAGE_LOADERS` map next to its message files instead of hazo_i18n generating or
centralizing one — the registry (step 1) can tell you WHICH locales exist, but each bundler-facing
loader map still has to spell out HOW to load each one's file, by hand, per locale.
Tailwind v4 @source: required for the admin panel's classes to render
<TranslationsAdminPanel> ships plain Tailwind utility classes compiled into dist/, not inline
styles. Tailwind v4's JIT scanner only emits CSS for classes it can see in files it scans — it
never scans node_modules by default — so unless your app's globals.css adds an explicit
@source pointing at this package's dist, every hazo_i18n class (borders, spacing, status pill
colors in message-grid.tsx, etc.) silently compiles to nothing and the panel renders unstyled.
hazo packages hoist to the workspace-root node_modules in local dev (see the workspace
CLAUDE.md's "Tailwind v4 @source Directive" section), so the relative path depends on how deep
your globals.css sits below the workspace root — count the directory levels up to
node_modules/hazo_i18n/dist:
@import "tailwindcss";
@source "../node_modules/hazo_i18n/dist";
@source "../node_modules/hazo_ui/dist";src/app/globals.cssin a consuming app installed via npm (not the monorepo) →../../node_modules/...(one level forapp/, one forsrc/).test-app/app/globals.cssinside the hazo monorepo → three levels up (test-app/app/→test-app/→ package root → workspace root):@source "../../../node_modules/hazo_i18n/dist";
Verify after building: grep -r '\.bg-emerald\|\.border-green' .next/**/*.css (or any other
hazo_i18n class you expect) should hit. An empty grep means the @source path is wrong for your
directory depth, not that the classes don't exist.
Sidebar components
hazo_i18n/ui also exports two small drop-in components for a consuming app's own sidebar —
neither requires <TranslationsAdminPanel> or the DB-authoring API surface, only the runtime
./client provider:
import { LocaleSwitcher, TranslateNavItem } from 'hazo_i18n/ui';
const LOCALES = [
{ code: 'en', label: 'English' },
{ code: 'de', label: 'Deutsch' },
{ code: 'es', label: 'Español' },
{ code: 'fr', label: 'Français' },
];
<LocaleSwitcher locales={LOCALES} />
<TranslateNavItem href="/translations" active={pathname === '/translations'} /><LocaleSwitcher> reads/writes the active locale via useLocale()/useSetLocale() (cookie
write + reload) and takes two independent style knobs:
display?: 'label' | 'code' | 'flag'— what the trigger shows beside the globe icon: the full language name (default), a two-letter uppercase code, or a flag emoji.localeToFlag(code)is also exported standalone for auto-deriving a flag from a locale code (35+ built-in mappings, globe glyph fallback); override per-locale with an explicitflagfield on anyLocaleSwitcherOption.variant?: 'button' | 'sidebar' | 'nav'— a standaloneButton variant="outline"(default), sidebar-native styling matching<TranslateNavItem>'s nav-item look, or'nav'(0.9.0+): a borderless plain-text trigger for horizontal top navs and footers (override colours/typography viaclassName; dropdown aligns to the end).- Renders nothing when
localeshas fewer than two entries (all variants, 0.9.0+). collapsed?: boolean— icon only, no text, for a collapsed rail sidebar (works with either variant).
The dropdown's own items always show flag + full name + two-letter code together, regardless of
display — that only controls the trigger.
<TranslateNavItem> is a Languages-icon nav link to wherever a <TranslationsPage> (below) or
<TranslationsAdminPanel> is mounted; next stays an optional peer (next/link is probed lazily,
falling back to a plain <a>).
hazo-i18n-audit CLI
Scans .tsx source for likely hardcoded/untranslated UI copy — JSX text nodes and
title/placeholder/aria-label/alt string-literal attributes — and suggests a
namespace.slug translation key for each finding. Regex-based (no TypeScript-compiler
dependency), exits 1 when anything is found so it can gate CI:
npx hazo-i18n-audit --dir src/components/ --min-length 3
npx hazo-i18n-audit --dir src/ --ignore "*.stories.tsx" --namespace my_appThe same scan function (scanSourceForHardcodedStrings) also powers a browser-side autotest
scenario for consumers that want the check to run client-side against already-loaded source.
Design decisions worth knowing
- Two-tier content model. Short UI messages (key → string, catalogs) are handled here. Long-form content (blog posts, marketing pages) is out of scope — that's locale-variant rows in the owning package's own content table (see PRD §3).
- Default locale stays unprefixed.
/countdownremains English;/de/countdownis additive. No existing URL moves (PRD §6.1). - Never auto-redirect by geo-IP. Detection only ever suggests — geo/Accept-Language never force a redirect (PRD §8.2).
More
See SETUP_CHECKLIST.md for consumer setup steps, CHANGE_LOG.md for version history, and
design/PRD-hazo_i18n.md (1800+ lines) as the source of truth for every API shape, schema
detail, and design rationale referenced above.
License
MIT
