codeebo-lang-tools
v0.2.0
Published
Typed, build-time localization for multi-domain and path-prefixed websites.
Downloads
551
Maintainers
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
--langparser; - 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, andhreflangmetadata;- 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/reactas 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-toolsReact 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 packInstall the resulting archive in another project to verify the consumer experience:
npm install ../path/codeebo-lang-tools-0.2.0.tgzRecommended 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.tsthat assembles the complete catalog for that language; - group repeatable content as objects instead of creating hundreds of flat keys;
- keep a shared technical
idindependent 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/beispielartikelEach 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/beispielartikelSet prefixDefaultLanguage to false to leave the default language unprefixed:
https://example.com/artykuly/przykladowy-artykul
https://example.com/en/articles/example-articleThe 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:
homenavigates to the target language's home page and is the default;same-pathkeeps the current application path;errorthrows 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.alternatesThe 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=plThe application script should:
- read
--lang; - validate it against the registry exported by
src/localization/index.ts; - set one environment variable consumed by the application;
- launch the relevant bundler process;
- 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
- Create
src/localization/locales/dewith the same contract as the existing catalogs. - Add one
deentry tosrc/localization/index.ts. - Add localized slugs to the data or route table.
- Configure deployment for the domain or prefix.
- 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:dryBefore the first release, sign in to npm and verify that codeebo-lang-tools is available:
npm login
npm publish --access publicFor 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
