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

codeebo-lang-tools

v0.2.0

Published

Typed, build-time localization for multi-domain and path-prefixed websites.

Downloads

551

Readme

Codeebo Lang Tools

A type-safe, framework-agnostic localization toolkit for static websites that are built separately for each language. It supports language versions hosted on separate domains or under path prefixes on a shared domain.

The package provides localization mechanisms only. It does not contain translations, domains, slugs, or data structures specific to any application.

Features

  • a single application-owned language registry;
  • a separate build selected through the reusable --lang parser;
  • domain-based and path-prefix routing;
  • readable fallback text at the point of use, with translation catalogs for other languages;
  • parameter interpolation such as {count};
  • registry-bound structured data tools and localized route registries;
  • language-switcher URL generation;
  • html lang, canonical, and hreflang metadata;
  • an optional React adapter with no React Router or head-management dependency;
  • no runtime dependencies in the core package;
  • ESM and CommonJS builds with declarations consumable by TypeScript 4.9+;
  • legacy TypeScript resolution for codeebo-lang-tools/react as well as modern package exports.

React is an optional peer dependency. Importing codeebo-lang-tools does not load React. The adapter is available separately from codeebo-lang-tools/react.

Installation

Available on npm: codeebo-lang-tools.

npm install codeebo-lang-tools

React 18 or newer must be installed in the consuming application when the React adapter is used.

To inspect the package locally before publication:

npm install
npm run typecheck
npm run build
npm pack

Install the resulting archive in another project to verify the consumer experience:

npm install ../path/codeebo-lang-tools-0.2.0.tgz

Recommended application structure

The application owns its languages and content. A typical structure looks like this:

src/localization/
├── index.ts
└── locales/
    ├── pl/
    │   ├── index.ts
    │   ├── ui/
    │   │   ├── common.ts
    │   │   └── navigation.ts
    │   └── data/
    │       └── articles/
    │           ├── index.ts
    │           └── ARTICLE_001.ts
    ├── en/
    │   ├── index.ts
    │   ├── ui/
    │   └── data/
    └── de/
        └── ...

The exact number of files is not important. The useful conventions are:

  • keep one directory per language;
  • expose one index.ts that assembles the complete catalog for that language;
  • group repeatable content as objects instead of creating hundreds of flat keys;
  • keep a shared technical id independent of language;
  • allow slugs, titles, summaries, source links, images, and attachments to live in localized data;
  • keep shared technical values such as published, record type, and stable configuration outside translations;
  • do not store JSX or HTML in localization data.

Application-specific route tables can live in src/routing, and application data types can live next to the data they describe. They do not need operational files inside src/localization.

A single localization configuration

src/localization/index.ts should be the only operational localization file. Adding a language means importing its catalog and adding one object to languages:

import {
  createBuildTimeLocalization,
  createLocalizedDataTools,
  type LanguageDefinition,
} from "codeebo-lang-tools";
import { createReactLocalization } from "codeebo-lang-tools/react";
import type { AppLocaleData } from "../data/types";
import pl from "./locales/pl";
import en from "./locales/en";

export const languages = [
  {
    code: "pl",
    label: "PL",
    flag: "🇵🇱",
    isDefault: true,
    origin: "https://example.pl",
    translations: pl.translations,
    structuredData: pl.data,
  },
  {
    code: "en",
    label: "EN",
    flag: "🇬🇧",
    origin: "https://example.com",
    translations: en.translations,
    structuredData: en.data,
  },
] as const satisfies readonly LanguageDefinition<string, AppLocaleData>[];

export type Language = (typeof languages)[number]["code"];

export const localization = createBuildTimeLocalization({
  languages,
  requestedLanguage: process.env.REACT_APP_LANGUAGE,
  fallbackToDefault: true,
  languageSourceName: "REACT_APP_LANGUAGE",
  routing: { mode: "domains" },
  missingRouteBehavior: "home",
});

export const languageRegistry = localization.registry;
export const buildLanguage = localization.currentLanguage;

export const {
  defaultLanguage,
  getLanguage,
  isLanguage,
  supportedLanguages,
} = languageRegistry;

export const localizedData = createLocalizedDataTools(languageRegistry);
export const currentData = localizedData.forLanguage(buildLanguage);
export const { useT } = createReactLocalization(localization);

Adding a language then means adding its catalog and one object to languages. The rest of the application reads the list from this registry.

Language catalogs

Each language exposes the same contract:

// locales/en/index.ts
import common from "./ui/common";
import navigation from "./ui/navigation";
import articles from "./data/articles";

export default {
  translations: {
    ...common,
    ...navigation,
  },
  data: {
    articles,
  },
};

A flat catalog works well for short interface strings:

// locales/en/ui/common.ts
export default {
  "common.book": "Book now",
  "common.duration": "Duration: {minutes} min",
} as const;

Repeatable entities such as articles should retain their own structure:

// locales/en/data/articles/ARTICLE_001.ts
const article = {
  slug: "example-article",
  title: "Example article",
  summary: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
  sourceUrl: "https://example.com/en/articles/example-article",
  images: ["/images/articles/example-article.webp"],
} as const;

export default article;
// locales/en/data/articles/index.ts
import ARTICLE_001 from "./ARTICLE_001";

export default { ARTICLE_001 };

Technical definitions can remain separate:

export const articles = [
  {
    id: "ARTICLE_001",
    published: true,
    category: "example",
    sortOrder: 1,
  },
] as const;

This makes adding an entity explicit: add one technical record and one complete localized object for every language. There is no need to create a separate translation key for every field or import languages into the technical definition.

Build-time localization

createBuildTimeLocalization creates and validates the registry, resolves the requested language, and creates the localization runtime in one operation. A missing build variable throws by default. Set fallbackToDefault: true explicitly to select the single language marked with isDefault; an unsupported non-empty value still throws an error.

The library does not select a language through Redux, Context, or localStorage. Each build has one known language, making its static HTML, SEO URLs, and data unambiguous.

Separate-domain routing

Each language declares its own origin, while routing uses domains mode:

const localization = createLocalization({
  languages,
  currentLanguage: "en",
  routing: { mode: "domains" },
  resolveLocalizedPaths,
});

Example output:

pl → https://example.pl/artykuly/przykladowy-artykul
en → https://example.com/articles/example-article
de → https://example.de/artikel/beispielartikel

Each version can be built and deployed independently to its target domain.

Shared-domain path prefixes

In this mode all languages share an origin. A language's optional pathPrefix overrides the default prefix, which is its language code.

const localization = createLocalization({
  languages,
  currentLanguage: "en",
  routing: {
    mode: "path-prefix",
    origin: "https://example.com",
    prefixDefaultLanguage: true,
  },
  resolveLocalizedPaths,
});

Output:

https://example.com/pl/artykuly/przykladowy-artykul
https://example.com/en/articles/example-article
https://example.com/de/artikel/beispielartikel

Set prefixDefaultLanguage to false to leave the default language unprefixed:

https://example.com/artykuly/przykladowy-artykul
https://example.com/en/articles/example-article

The server must direct every prefix to the correct build. If the bundler emits absolute asset paths, configure its public base path as well, for example /en.

Text inside components

Readable fallback text can stay at the point of use:

const { T } = useT("auth.users");

return (
  <>
    <h1>{T("title", "Users")}</h1>
    <label>{T("surname", "Surname")}</label>
    <span>{T("count", "Count: {count}", { count: 3 })}</span>
  </>
);

When a language has no catalog or the requested entry is missing, the fallback is returned. An empty catalog entry is also treated as missing.

Without React:

localization.t("auth.users.title", "Users");
localization.t("common.duration", "Duration: {minutes} min", { minutes: 60 });

Text outside components

Use defineText for navigation, configuration, or metadata strings:

import { defineStaticText, defineText } from "codeebo-lang-tools";

const navigationItem = {
  label: defineText("navigation.articles", "Articles"),
};

const companyName = defineStaticText("Example Company");

localization.translateText(navigationItem.label);
localization.translateText(companyName);

defineStaticText marks an intentionally untranslated value such as a proper name, phone number, email address, or a price shared by all languages.

Repeatable structured data

The simplest option is to read data for the current language directly from the registry:

const article = localization
  .getLanguage(localization.currentLanguage)
  .structuredData.articles.ARTICLE_001;

When an application combines technical records with localized content, bind the data tools to the registry once:

import { createLocalizedDataTools } from "codeebo-lang-tools";
import { buildLanguage, languageRegistry } from "../localization";

const dataTools = createLocalizedDataTools(languageRegistry);

const localizedArticles = dataTools.localizeCollection(articles, {
  categoryName: "articles",
  getContent: (language, id) => language.structuredData.articles[id],
  resolve: (technical, content) => ({
    ...technical,
    ...content,
  }),
});

const currentData = dataTools.forLanguage(buildLanguage);
export const currentArticles = currentData.resolveDataList(localizedArticles);

Missing content for any language produces an error containing the category and record id, preventing incomplete localized entities from being built silently.

Localized slugs and equivalent pages

The same entity should keep a stable technical id while using localized slugs:

import { createLocalizedRouteRegistry } from "codeebo-lang-tools";
import { buildLanguage, languageRegistry } from "../localization";

const staticRoutes = {
  home: { pl: "/", en: "/" },
  articles: {
    pl: "/artykuly",
    en: "/articles",
  },
} as const;

export const routes = createLocalizedRouteRegistry({
  registry: languageRegistry,
  currentLanguage: buildLanguage,
  routes: staticRoutes,
  aliases: { articles: ["/aktualnosci"] },
  details: {
    article: { parent: "articles", items: localizedArticles },
  },
});

routes.routePath("articles");
routes.routePaths("articles");
routes.detailRoutePaths("article");
routes.detailPath("article", "ARTICLE_001");
routes.resolveLocalizedPaths("/articles/example-article");
routes.getCanonicalPath("/artykuly/przykladowy-artykul");

resolveLocalizedPaths always receives an application path without a language prefix. It returns variants of the same logical page, used by language switchers, canonical URLs, and hreflang links.

Static route aliases are accepted as incoming paths and resolve to the canonical localized route. Detail items are matched by stable id or by a slug from any language. By default, published: false removes only that language's equivalent; isItemAvailable can replace this rule.

Configure missing equivalents with missingRouteBehavior:

  • home navigates to the target language's home page and is the default;
  • same-path keeps the current application path;
  • error throws an error.

HTTP redirects for old slugs still belong in the hosting or deployment configuration.

URLs and language detection

localization.buildUrl("en", {
  pathname: "/articles/example-article",
  search: "?preview=true",
  hash: "#details",
});

localization.getLanguageOptions({
  pathname: window.location.pathname,
  search: window.location.search,
  hash: window.location.hash,
});

localization.detectLanguage(window.location.href);
localization.stripLanguagePrefix("/en/articles/example-article");

detectLanguage uses the domain in domains mode and the prefix in path-prefix mode.

React adapter

The adapter creates hooks and components tied to a localization instance:

// src/localization/react.ts
import { createReactLocalization } from "codeebo-lang-tools/react";
import { localization } from ".";

export const {
  useT,
  useLanguageSwitcher,
  LocalizationDocument,
  LanguageSelect,
  LanguageLinks,
} = createReactLocalization(localization);

No provider is required because localization is immutable within a build.

html lang, canonical, and hreflang

Mount LocalizationDocument once, as high in the application tree as practical:

function AppLayout() {
  const location = useLocation();

  return (
    <>
      <LocalizationDocument location={{ pathname: location.pathname }} />
      <Outlet />
    </>
  );
}

It synchronizes:

  • <html lang="...">;
  • <link rel="canonical">;
  • <link rel="alternate" hreflang="..."> for available equivalents;
  • hreflang="x-default" pointing to the default language.

The component intentionally modifies the document only after mounting. Importing the library has no hidden document side effects.

For SSR or a custom head-management system, use the pure API:

const metadata = localization.getDocumentMetadata({
  pathname: request.pathname,
}, routes.resolveLocalizedPaths);

// metadata.htmlLang
// metadata.canonical
// metadata.alternates

The route resolver may be supplied when the localization instance is created or later to route-aware calls. Passing it later avoids circular imports in applications where route definitions consume localized structured data:

const options = localization.getLanguageOptions(
  location,
  routes.resolveLocalizedPaths,
);

The React adapter can bind the same resolver when its route-aware components are created outside the central localization configuration:

const routeAwareReact = createReactLocalization(localization, {
  resolveLocalizedPaths: routes.resolveLocalizedPaths,
});

Select menu

<LanguageSelect className="language-select" />

The component is intentionally unstyled. By default it performs full-page navigation through window.location.assign, which also works across domains and separate builds.

Custom navigation is optional:

<LanguageSelect
  location={{ pathname, search, hash }}
  navigate={(url) => router.navigate(url)}
  showFlags={false}
/>

Language links

Links work without JavaScript and are easy for crawlers to discover:

<LanguageLinks
  className="languages"
  listClassName="languages__list"
  itemClassName="languages__item"
  linkClassName="languages__link"
/>

Custom switcher

function CustomLanguageSwitcher() {
  const { options, selectLanguage } = useLanguageSwitcher();

  return (
    <div>
      {options.map((option) => (
        <button
          key={option.code}
          disabled={option.active}
          onClick={() => selectLanguage(option.code)}
        >
          {option.label}
        </button>
      ))}
    </div>
  );
}

The adapter does not depend on React Router. Applications can provide only their current pathname, search, and hash, or a custom navigate function.

Selecting a build with --lang

The library does not prescribe a bundler or environment-variable convention. A useful application contract is:

npm run build -- --lang=en
npm run start -- --lang=pl

The application script should:

  1. read --lang;
  2. validate it against the registry exported by src/localization/index.ts;
  3. set one environment variable consumed by the application;
  4. launch the relevant bundler process;
  5. set the bundler's public base path in path-prefix mode.

Example script executed with tsx:

import { spawnSync } from "node:child_process";
import { resolveLanguageArgument } from "codeebo-lang-tools";
import { languageRegistry } from "../src/localization";

const command = process.argv[2];
const { language, forwardedArguments } = resolveLanguageArgument(
  languageRegistry,
  process.argv.slice(3),
  { sourceName: "--lang" },
);

const result = spawnSync("react-scripts", [command, ...forwardedArguments], {
  env: {
    ...process.env,
    REACT_APP_LANGUAGE: language,
  },
  shell: true,
  stdio: "inherit",
});

process.exit(result.status ?? 1);

The application's package.json can remain stable:

{
  "scripts": {
    "start": "tsx scripts/run-localized.ts start",
    "build": "tsx scripts/run-localized.ts build"
  }
}

There is no need for .env.pl, .env.en, separate build:pl and build:en scripts, or repeated language imports across the application.

Adding another language

  1. Create src/localization/locales/de with the same contract as the existing catalogs.
  2. Add one de entry to src/localization/index.ts.
  3. Add localized slugs to the data or route table.
  4. Configure deployment for the domain or prefix.
  5. Run npm run build -- --lang=de.

Hooks, language switchers, the Language type, canonical URLs, and hreflang metadata all derive from the registry automatically.

Building and publishing

npm install
npm run typecheck
npm run build
npm run pack:dry

Before the first release, sign in to npm and verify that codeebo-lang-tools is available:

npm login
npm publish --access public

For subsequent releases, update the version according to semantic versioning. npm publish invokes prepack, so the package is rebuilt before publication.

Sponsor

This open-source project is sponsored by Codeebo.

License

MIT