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

paraguas

v0.4.0

Published

Typed i18n standard on the keys-weaver engine: recipe-based locale build pipeline, ICU + <Trans> embedded components, typed runtime proxy, and a server-side loader

Downloads

2,549

Readme

paraguas

npm CI license

One typed translation package. Every consumer world. React in the browser, Node services on the server — shared keys, shared types, build-time guarantees.

// "cta": "See <readMore>Read more</readMore>"

texts.cart.cta({}, { readMore: <a href={url}/> });  // ✅ chunk becomes the link text

texts.cart.cta({});
// ❌ tsc: Expected 2 arguments, but got 1 — the compiler won't let
//    literal <readMore>…</readMore> tags reach your users

paraguas is the mechanism for running i18n as a first-class package in a TypeScript monorepo: a recipe-based build pipeline, a typed runtime proxy with embedded-component rendering, and a server-side loader. Your project owns the locale JSONs, the recipe definitions, and the generated types; paraguas owns validation, merging, codegen orchestration, and runtime resolution. It bundles keys-weaver as its default type generator.

npm install paraguas

The problem it solves

Translations usually live in one place: the frontend. Then transactional emails, PDFs, CSV error messages, and scheduled jobs start leaking user-facing copy back into TypeScript string literals. Second-language coverage drifts. Nobody can review server-rendered output. CI can't catch missing keys outside the web app.

The fix is structural: make the translation package a dependency any TypeScript code can consume — same typed key inference, same ICU support, same locale files — whether it renders React in a browser or an email on a server.

Concepts

  • Namespace — one locale JSON file per language (locales/en/catalog.json, locales/fr/catalog.json). Nested JSON, ICU MessageFormat values.
  • Recipe — a named subset of namespaces, deep-merged at build time into one bundle per consumer per language (dist/<recipe>/<lang>.json). The web app takes all namespaces; the email service takes only what it renders.
  • Token structure — a pattern for tokens inside translation values. The built-in angleTagStructure matches <tag>label</tag> pairs (HTML/ICU-style; basic HTML tags br/strong/i/p are excluded by default) used to embed components (links, buttons) inside copy.
locales/                         dist/                     (per recipe × language)
├── en/                          ├── web/
│   ├── catalog.json      →      │   ├── en.json
│   ├── cart.json         →      │   └── fr.json
│   ├── emails.json       →      └── emails/
│   └── common.json       →          ├── en.json
└── fr/  (mirror of en/)             └── fr.json

recipes: { web: ['catalog', 'cart', 'common'], emails: ['emails', 'common'] }

Why recipes?

The email service never renders the product catalog; the web app never renders an order-confirmation email. Each consumer pulls only the namespaces it needs — smaller bundles, narrower types. And when the same namespace is in both recipes (common), copy on screen ≡ copy in the email, guaranteed by sharing the source file.

LocaleKeysOf types follow the recipe: the merged type for emails literally does not contain the catalog tree, so referencing it is a compile error — not a runtime miss.

Deep-merge mechanics

Two namespaces may contribute to the same parent branch. Say cart.json (web-only) and common.json (shared) both root at shop.actions:

// cart.json (web recipe only)                   // common.json (web + emails)
{ "shop": { "actions": {                         { "shop": { "actions": {
    "buttons": {                                     "buttons": {
      "addToCart": "Add to cart",                      "viewOrder": "View order",
      "clear": "Clear"                                 "trackShipment": "Track shipment"
    }                                                },
} } }                                                "status": { "shipped": "Shipped" }
                                                 } } }

The web bundle's shop.actions.buttons has all four leaves; the emails bundle only viewOrder/trackShipment. Shared parents merge; leaves must have exactly one owner — if two namespaces define the same leaf path, the build fails naming both owners.

paraguas/build — the pipeline

import { build, angleTagStructure } from 'paraguas/build';

await build({
    localesDir: 'locales',
    distDir: 'dist',
    generatedDir: 'src/generated',
    languages: ['en', 'fr'],
    recipes: { web: ['catalog', 'cart', 'common'], emails: ['emails', 'common'] },
    structures: [angleTagStructure],
    codegen: { layout: 'single-file', sortKeys: true, emitFactory: false },
});

build(config, extras?) parameters

| Parameter | Type | Required | Description | | --- | --- | --- | --- | | localesDir | string | ✅ | Root of the locale sources; expects <localesDir>/<lang>/<namespace>.json | | distDir | string | ✅ | Output root for merged bundles: <distDir>/<recipe>/<lang>.json | | languages | readonly string[] | ✅ | All languages; languages[0] is the reference language — every other language is validated against it, and it is the fallback source | | recipes | Record<string, readonly string[]> | ✅ | Recipe name → ordered namespace list. Order matters only for merge precedence of shared parents | | generatedDir | string | — | Where generated key types + namespace-type-map.ts go. Omit to skip codegen entirely | | structures | TokenStructure[] | — | Token structures to validate cross-language (see below). Default: none | | codegen | Omit<GenerateOptions, 'source' \| 'output' \| 'functionName'> | — | Passthrough to the bundled keys-weaver generator: layout ('single-file' | 'per-node'), sortKeys, emitFactory, comments, banner, structures. Codegen structures default to paraguas's i18n standard — [icuData(), taggedEmbeds()] (typed ICU params + <tag> embeds, basic HTML excluded), both exported from paraguas/build | | generate | (req: { source, output, functionName }) => Promise \| unknown | — | Replace the bundled generator entirely (custom codegen). When set, codegen is ignored | | functionNameFor | (namespace: string) => string | — | Generated type/file base name per namespace. Default: PascalCase + Keys (order-emailsOrderEmailsKeys) | | extras.typeMapTypeParams | string[] | — | Generic params for namespace-type-map.ts when a custom generator emits generic types. Unnecessary with the bundled generator |

TokenStructure (validation-side)

| Field | Type | Description | | --- | --- | --- | | id | string | Name used in error messages (unpaired embed token(s)…) | | pattern | RegExp | Global regex; capture group 1 = token name, group 2 = label. angleTagStructure = /<(\w+)>(.*?)<\/\1>/g | | malformedPattern | RegExp? | Detects leftovers after well-formed pairs are stripped (/<\/?\w+>/g) — catches <readMore>…</readMor> typos |

Pipeline order — every run

  1. Validate — refuses to ship a drifted bundle:
    • every namespace file exists in every language;
    • no leaf-path collisions across namespaces (each leaf has one owner);
    • key parity between the reference language and every other (Missing in fr: … / Extra in fr: … — no half-translations);
    • token parity per structure: unpaired tags and cross-language tag mismatches are build errors — a translation can move a tag to a different sentence position, but never drop or rename it.
  2. Merge — deep-merge each recipe's namespaces, sort keys deterministically, write dist/<recipe>/<lang>.json.
  3. Codegen — generate typed key functions per namespace, in-process (no CLI spawning), then emit namespace-type-map.ts.
  4. Prune — delete generated files whose namespace no longer exists.

All validation failures throw one ValidationError listing every mismatch. Typical wiring: run on postinstall and in CI so dist/ is always fresh and PR diffs show the merged output.

paraguas — the runtime

Browser-safe entry: types, the proxy, token rendering, locale guards. No filesystem access.

createLocaleProxy<T>(t, options?)

| Parameter | Type | Description | | --- | --- | --- | | t | (key: string, values?: Record<string, unknown>) => string | Your translate function — i18next's t in a web app, paraguas's own resolver on a server | | options.renderKey | RenderKey? | (path, data, wrappers) => unknown — invoked with the whole call when embed wrappers are passed (t is never called for that key). Omit it and any wrappers call throws MissingRenderKeyError |

The proxy turns property access into dotted key paths and calls t. When the trailing argument is a wrapper record (an object whose values are all React elements or all functions), the entire call is delegated to renderKey — in an i18next app that means <Trans> owns resolution and element substitution.

texts.cart.summary({ count: 3 });                                   // plain ICU key → string
texts.cart.emptyHint({}, { browse: <a href="/"/> });                // embed key → renderKey result

angleTagStructure — the <tag>label</tag> pattern (/<(\w+)>(.*?)<\/\1>/g, basic HTML tags excluded) — is exported for build-time validation and for anyone building custom tooling on the same convention.

resolveLocale(raw, locales, localeSet)

Per-request pick from a preloaded map: accepts anything (query param, header, stored preference); non-string or unsupported input falls back to DEFAULT_LOCALE; a supported-but-not-preloaded locale throws LocaleNotPreloadedError.

createLocaleSet(locales)

createLocaleSet(['en', 'fr'] as const){ SUPPORTED_LOCALES, DEFAULT_LOCALE, isSupportedLocale } as one typed unit. DEFAULT_LOCALE is the first entry.

defineLocalePackage({ languages, recipes })

The one-call glue for a consumer monorepo's i18n package — returns everything the pieces above would be wired into by hand:

| Field | What it is | | --- | --- | | localeSet | The createLocaleSet result for languages | | languages, recipes | Passthrough, const-typed — spread into build() | | loadOptions(distDir, proxy?) | LoadOptions factory for the server loaders | | resolve(raw, locales) | resolveLocale bound to the package's locale set |

LocaleKeysFor<typeof pkg, NamespaceTypeMap, R> derives the recipe-keyed translation type from the package object. See the full setup below.

Type utilities

| Utility | What it gives you | | --- | --- | | LocaleKeysOf<Recipes, TypeMap, R> | The deeply-merged translation type for a recipe | | NestedPaths<T> | Union of every dotted key path — autocomplete on 'shop.actions.buttons.viewOrder' \| … | | GetNestedValue<T, Path> | The value type at a path — define a Texts<P> alias once in your glue package and use it in every helper signature | | DeepMerge<[A, B, …]> | The type-level twin of the build-time merge | | LocaleKeysFor<Pkg, TypeMap, R> | LocaleKeysOf keyed off a defineLocalePackage object |

paraguas/react-i18next — the <Trans> seam (recommended for i18next apps)

Tagged keys render through react-i18next's <Trans> components mapping — the translated chunk becomes the element's children (react/react-i18next/i18next are optional peers):

import { createUseLocaleKeys } from 'paraguas/react-i18next';

export const useTexts = createUseLocaleKeys<WebKeys>();

texts.cart.cta({}, { readMore: <a href={url}/> });
// → <Trans i18nKey="cart.cta" components={{ readMore: <a href={url}/> }} />

The hook always renders tagged keys through <Trans> (transRenderKey is also exported for wiring createLocaleProxy manually). The proxy delegates the whole call — Trans resolves via t() (ICU formats first; i18next-icu ships ignoreTag: true, so tags survive as literal text), then substitutes tags with your elements. Basic HTML tags (<i>, <strong>, <br/>, <p>) render natively and are excluded from detection and typing.

Embeds inside ICU plurals

Tags compose with ICU plural/select because ICU resolves first (branch selected, # substituted — angle tags are literal text to ICU), then the surviving branch's tags render:

// "{count, plural, one {<undo>Undo # item</undo>} other {# items — <undo>undo all</undo>}}"
texts.cart.removed({ count: 1 }, { undo: <button/> });

paraguas/server — the loader

Node-only entry: filesystem loaders reading the pre-built recipe bundles. No HTTP, no React.

Loader functions

| Function | Returns | Use | | --- | --- | --- | | loadTypedLocale<T>(recipe, lang, options) | T | One typed proxy for one recipe × language | | preloadTypedLocales<T>(recipe, options) | Map<string, T> | One proxy per language — call once at boot | | loadLocale(recipe, lang, options) | TranslationResolver | Lower-level: the raw t(key, values?) resolver | | preloadLocales(recipe, options) | Map<string, TranslationResolver> | All languages as raw resolvers |

LoadOptions

| Field | Type | Required | Description | | --- | --- | --- | --- | | distDir | string | ✅ | Where the built bundles live — explicit so tests can point at fixtures | | languages | readonly string[] | ✅ | Languages to accept/preload; languages[0] is the fallback source unless overridden | | fallbackLanguage | string? | — | Override the fallback source language | | proxy | LocaleProxyOptions? | — | Options for the typed proxy (renderKey). Server recipes normally carry no embed tags — keep component embeds in browser-rendered namespaces |

Semantics: ICU resolves via intl-messageformat with the requested locale's plural and formatting rules (French output gets French plural categories and number grouping). A key missing in the requested language falls back to the reference language; missing in both throws TranslationKeyError. No hot-reload by design — language is a request-time decision over a boot-time preload.

Best practice — full setup

Monorepo layout: one internal package owns the locale content; every consumer depends on it. defineLocalePackage collapses the glue to a single call:

// packages/my-i18n/src/package.ts — languages + recipes, once
import { defineLocalePackage } from 'paraguas';

export const i18nPackage = defineLocalePackage({
    languages: ['en', 'fr'] as const,
    recipes: { web: ['catalog', 'cart', 'common'], emails: ['emails', 'common'] } as const,
});
// → { localeSet, loadOptions(distDir, proxy?), resolve(raw, locales), languages, recipes }
packages/my-i18n/
├── locales/{en,fr}/{catalog,cart,emails,common}.json
├── src/
│   ├── package.ts        # the defineLocalePackage call above
│   ├── build.ts          # the build() call above; run on postinstall + CI
│   ├── index.ts          # type glue + paraguas re-exports (below)
│   └── generated/        # committed output of the build
└── server.ts             # subpath for Node consumers
// packages/my-i18n/src/index.ts — the type glue every consumer imports
import type { GetNestedValue, LocaleKeysFor, NestedPaths } from 'paraguas';
import type { NamespaceTypeMap } from './generated/namespace-type-map';
import { i18nPackage } from './package';

export type LocaleKeys<R extends keyof typeof i18nPackage.recipes> = LocaleKeysFor<typeof i18nPackage, NamespaceTypeMap, R>;
export type EmailKeys = LocaleKeys<'emails'>;
export type EmailTexts<P extends NestedPaths<EmailKeys>> = GetNestedValue<EmailKeys, P>;
export { createLocaleProxy } from 'paraguas';
export { i18nPackage };

The build call binds the same package object:

// packages/my-i18n/src/build.ts
await build({ localesDir, distDir, generatedDir, ...i18nPackage, structures: [angleTagStructure] });

Frontend (React + i18next)

// web/src/i18n.ts
import type { LocaleKeys } from 'my-i18n';
import { createUseLocaleKeys } from 'paraguas/react-i18next';

export type WebKeys = LocaleKeys<'web'>;
export const useTexts = createUseLocaleKeys<WebKeys>();
// web/src/cart/CartBanner.tsx
const { t: texts } = useTexts();

<p>{texts.cart.summary({ count: items.length })}</p>
<p>{texts.cart.emptyHint({}, { browse: <Link to="/catalog"/> })}</p>

i18next loads my-i18n/dist/web/<lang>.json (bundle it in dev, fetch it lazily in prod) — paraguas doesn't care how the bundle reaches i18next.

Node service (emails)

// emails/src/i18n.ts
import { i18nPackage, type EmailKeys } from 'my-i18n';
import { preloadTypedLocales } from 'paraguas/server';

const distDir = require.resolve('my-i18n/package.json').replace('package.json', 'dist');
const options = i18nPackage.loadOptions(distDir);
export const preloadEmailLocales = () => preloadTypedLocales<EmailKeys>('emails', options);
// emails/src/server.ts — boot once, pick per request
import { i18nPackage } from 'my-i18n';

const locales = preloadEmailLocales();

app.post('/order-shipped', (req, res) => {
    const { t, lang } = i18nPackage.resolve(req.query.lang, locales);
    sendEmail(renderShippedEmail({ t: t.emails.orderShipped, order }));
});

Thread namespace slices, not the whole tree — through the EmailTexts alias the glue package exports:

function renderShippedEmail({ t, order }: { t: EmailTexts<'emails.orderShipped'>; order: Order }) {
    return `${t.subject({ orderId: order.id })}\n${t.body({ eta: order.eta })}`;
}

Tests — real bundles, key-based assertions

const tFr = loadTypedLocale<EmailKeys>('emails', 'fr', options);

it('renders the French shipped subject', () => {
    const email = renderShippedEmail({ t: tFr.emails.orderShipped, order });
    expect(email).toContain(tFr.emails.orderShipped.subject({ orderId: order.id }));
    // never a literal "Votre commande…" — copy changes must not break tests
});

Don't mock the i18n layer — the real loader is a sync fs read and catches copy bugs mocks hide.

paraguas/react-i18next — the hook factory

For React apps on i18next, the consumer hook is one line (react-i18next + i18next are optional peers):

import { createUseLocaleKeys } from 'paraguas/react-i18next';

export const useTexts = createUseLocaleKeys<WebKeys>();

const { t: texts } = useTexts();               // whole tree
const { t: actions } = useTexts('shop.actions'); // typed namespace slice

The hook returns { t, i18n, ready }; getNestedValue (exported from the main entry) is the runtime twin of the GetNestedValue type if you slice manually.

Using it right

// ❌ concatenating translated fragments — breaks word order in other languages
const msg = t.errors.notFound() + ' ' + t.actions.retry();
// ✅ one key owning the whole sentence, ICU params inside

// ❌ string surgery on translated text
t.greeting().replace('NAME', name);
// ✅ ICU param
t.greeting({ name });

// ❌ handing a helper the entire locale tree
function fmt(t: EmailKeys) { … }
// ✅ the slice it needs, via the alias defined once in the glue package
function fmt(t: EmailTexts<'emails.orderShipped'>) { … }

How it compares

Different tools optimize for different things — this is where paraguas + keys-weaver sit:

| | paraguas + keys-weaver | i18next (+ react-i18next) | typesafe-i18n | Lingui | | --- | --- | --- | --- | --- | | Typed key paths | ✅ generated per namespace | ⚠️ via manual type augmentation | ✅ | ⚠️ ids are strings | | Typed ICU params per key | ✅ required function args | ⚠️ partial | ✅ | ❌ runtime | | Compile-enforced embedded components | ✅ per-key wrapper args | ❌ <Trans> is untyped per key | ❌ | ❌ <Trans> is untyped per key | | Node services from the same keys | ✅ first-class loader | ⚠️ possible, DIY | ⚠️ | ⚠️ react-centric | | Per-consumer bundles (recipes) | ✅ | ❌ | ❌ | ❌ | | Locale-JSON git merge driver | ✅ shipped CLI | ❌ | ❌ | ❌ | | Runtime framework | bring your own (works with i18next) | i18next | own | own |

paraguas is a mechanism, not a runtime replacement — the recommended frontend setup runs on top of i18next and adds the typing, recipes, and embed enforcement it lacks.

Guarantees at a glance

| Layer | Guarantee | | --- | --- | | Compile time | Unknown key, missing ICU param, key referenced outside your recipe, embed key without wrappers — all tsc errors | | Build time | Missing namespace file, leaf-path collision, key-parity drift, unpaired/renamed embed tags — all ValidationErrors | | Runtime | Requested-language miss falls back to the reference language; miss in both throws TranslationKeyError; unsupported locale input falls back to the default |

paraguas-merge-locales — git merge driver

Locale JSONs conflict constantly on busy branches. The shipped CLI resolves them semantically — one side changed a key → take it; both made the same change → keep it; both changed it differently → fail as a real conflict:

# .gitattributes
locales/**/*.json merge=locale-json

# register the driver
git config merge.locale-json.driver 'paraguas-merge-locales --driver %O %A %B'

# or fix a file that already has conflict markers
paraguas-merge-locales --resolve locales/en/catalog.json

License

MIT