npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 stale and 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_i18n

hazo_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|publish

Zero-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 called createI18nRuntime), createPackageT falls back to simple {{var}} mustache-style interpolation directly against the bundled English object — no next-intl, no database, no config file required.
  • If the runtime is registered, createPackageT resolves through the merged catalog registry instead, so an app that has set up hazo_i18n gets translated strings, locale fallback, and staleness tracking for the same call site.
  • Adoption is therefore never a breaking change — a package can start using createPackageT today 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:

  1. Translate Outstanding — ?action=translate with scope 'missing+draft+fix+stale'.
  2. 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 in hazo_i18n_messages.review_reason.
  3. 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.

  1. One-time: add a LocaleDef to the app's createI18nRuntime({ locales: [...] }) call for every locale it serves, including ones added later — the registry is populated once, at startup, from that one list (see registerLocales()'s own doc comment).
  2. Per-locale: create src/messages/<code>.json for each package/app bundle that ships its own translations.
  3. Per-locale: add <code>: () => import('./messages/<code>.json') to each MESSAGE_LOADERS map that loads those bundles.
  4. One-time: pass locales={getLocales()} from a server layout to <HazoI18nProvider>, so client components can call useLocales() 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.css in a consuming app installed via npm (not the monorepo) → ../../node_modules/... (one level for app/, one for src/).
  • test-app/app/globals.css inside 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 explicit flag field on any LocaleSwitcherOption.
  • variant?: 'button' | 'sidebar' | 'nav' — a standalone Button 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 via className; dropdown aligns to the end).
  • Renders nothing when locales has 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_app

The 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. /countdown remains English; /de/countdown is 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