@watchgold/i18n-kit
v0.1.0
Published
Locale metadata helpers, battle-tested hreflang/canonical builders for multilingual sites, and a translation-parity checker.
Maintainers
Readme
@watchgold/i18n-kit
Locale metadata helpers, battle-tested hreflang/canonical builders for multilingual sites, and a translation-parity checker.
Zero dependencies, framework-free. The builder return shapes drop straight into the Next.js Metadata alternates field, but nothing here imports from Next — use the package with any framework that lets you emit canonical and alternate-language links, and run the parity checker from any test runner or CI script.
The lessons baked into this package come from operating an 11-locale production site through Google Search Console incidents, not from reading the hreflang spec.
npm install @watchgold/i18n-kitDefining your locale set
One typed definition carries every shape of locale metadata a multilingual site needs — routing codes, BCP 47 forms, OpenGraph forms, picker labels, RTL flags — so the language picker, the <html> shell, and the alternate links can never drift apart.
// i18n/locale-set.ts
import { defineLocaleSet } from '@watchgold/i18n-kit';
export const localeSet = defineLocaleSet({
locales: ['en', 'zh', 'ar'] as const,
defaultLocale: 'en',
rtlLocales: ['ar'],
labels: { en: 'English', zh: '中文', ar: 'العربية' },
htmlLang: { zh: 'zh-CN' }, // <html lang> / hreflang keys; others default to the code
ogLocale: { en: 'en_US', zh: 'zh_CN' }, // og:locale form; others default to the code
});defineLocaleSet throws at module load if defaultLocale is not in locales, so a typo fails your build instead of quietly emitting broken canonical URLs.
Typical layout usage — both attributes server-rendered so crawlers see them and there is no LTR→RTL flip after hydration:
// app/[lang]/layout.tsx
export default async function LocaleLayout({ children, params }) {
const { lang } = await params;
if (!localeSet.isLocale(lang)) notFound();
return (
<html lang={localeSet.htmlLang(lang)} dir={localeSet.dir(lang)}>
<body>{children}</body>
</html>
);
}Three canonicalization strategies for multilingual sites
A site with locale-prefixed routing (/en/..., /zh/..., /ar/...) serves every page at N URLs. Whether those URLs are alternates of each other or duplicates of one page depends entirely on the content behind them — and declaring the wrong thing costs indexing. Search engines do not read your intent; they compare the rendered pages.
This package encodes the three cases that cover a real multilingual site. All builders take the locale set as their first argument and build URLs as ${baseUrl}/${locale}${pathSuffix} (no trailing slash on baseUrl; pathSuffix empty or starting with /).
| Content situation | Builder | Canonical | hreflang cluster |
| --- | --- | --- | --- |
| Fully translated per locale (UI pages, localized landing pages) | buildAlternateLanguages | self | all locales + x-default |
| One default-language text rendered under every prefix (DB/CMS content) | buildDefaultLanguageContentAlternates | default-locale URL | none |
| Translated per row, backfilling over time | buildTranslatedContentAlternates | self if translated, else default-locale URL | translated locales + default + x-default, or none |
1. Fully translated pages — buildAlternateLanguages
For pages whose content genuinely differs per locale: every locale version is a real alternate, so each one self-canonicalizes and declares the full hreflang cluster. The map is keyed by each locale's BCP 47 htmlLang form (Google matches hreflang values against BCP 47, not your routing codes), and x-default points at the default locale.
// app/[lang]/pricing/page.tsx
import type { Metadata } from 'next';
import { buildAlternateLanguages } from '@watchgold/i18n-kit';
import { localeSet } from '@/i18n/locale-set';
const siteUrl = 'https://example.com';
export async function generateMetadata({ params }): Promise<Metadata> {
const { lang } = await params;
return {
alternates: {
canonical: `${siteUrl}/${lang}/pricing`,
languages: buildAlternateLanguages(localeSet, siteUrl, '/pricing'),
},
};
}Emits (for the en/zh/ar set above):
{
"en": "https://example.com/en/pricing",
"zh-CN": "https://example.com/zh/pricing",
"ar": "https://example.com/ar/pricing",
"x-default": "https://example.com/en/pricing"
}2. Single-language content under every prefix — buildDefaultLanguageContentAlternates
The trap. Sites with DB or CMS content — articles, editorial notes, generated summaries — usually render that text in its original language under every locale prefix, translating only the surrounding chrome. /zh/news/some-article and /ar/news/some-article are then duplicates of the default-language page, not translations of it.
Declaring the full hreflang cluster with self-canonicals here tells the crawler "these N pages are distinct language versions", and then the crawler reads N identical bodies. This exact configuration got every locale variant of such pages crawled and dropped as duplicates — "Crawled – currently not indexed" in Search Console — wasting crawl budget and diluting the one URL that could rank.
The fix: every locale URL canonicalizes to the default-locale URL, and no hreflang cluster is declared at all. One indexable page, N reachable URLs.
// app/[lang]/news/[slug]/page.tsx — content stored in one language only
import type { Metadata } from 'next';
import { buildDefaultLanguageContentAlternates } from '@watchgold/i18n-kit';
import { localeSet } from '@/i18n/locale-set';
const siteUrl = 'https://example.com';
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
return {
alternates: buildDefaultLanguageContentAlternates(localeSet, siteUrl, `/news/${slug}`),
};
}Every locale renders { canonical: "https://example.com/en/news/<slug>" } — including the default locale itself.
3. Per-row translations that backfill over time — buildTranslatedContentAlternates
The middle ground. Row-level content often gains translations incrementally: each row carries the list of locales that have a real stored translation (an available_langs column, a translations join table, ...). A single site-wide rule is wrong for both ends, so the alternates must be decided per row, per locale:
- A locale with a stored translation is a true alternate: it self-canonicalizes and declares a cluster covering only the translated locales plus the default (the original) and
x-default. - A locale still serving the default-language fallback is a duplicate: it canonicalizes to the default-locale URL and declares no cluster — the same reasoning as strategy 2.
- Codes outside the locale set are ignored; an unsupported current locale is treated as the duplicate case.
As translations backfill, rows migrate from the duplicate case to the cluster case automatically — no metadata code changes.
// app/[lang]/news/[slug]/page.tsx — rows carry availableLangs
import type { Metadata } from 'next';
import { buildTranslatedContentAlternates } from '@watchgold/i18n-kit';
import { localeSet } from '@/i18n/locale-set';
const siteUrl = 'https://example.com';
export async function generateMetadata({ params }): Promise<Metadata> {
const { lang, slug } = await params;
const article = await fetchArticle(slug); // { availableLangs: ['zh'], ... }
return {
alternates: buildTranslatedContentAlternates(
localeSet,
siteUrl,
`/news/${slug}`,
article.availableLangs,
lang,
),
};
}With availableLangs: ['zh'], the zh page emits a self canonical plus a {en, zh-CN, x-default} cluster; the ar page (still showing the default-language fallback) emits only the default canonical; and once ar gains a stored translation the same code emits the three-locale cluster.
Translation parity checking
The other half of running many locales: keeping every dictionary structurally identical to the source-of-truth locale. parity.ts is framework-free — flatten, diff, and validate nested dictionaries from any test runner or a plain CI script.
getLeafPaths(obj)— sorted leaf key paths, array-index aware:{ a: [{ b: 'x' }] }→['a.0.b'].resolveLeafPath(obj, path)— value at a dotted path (undefinedwhen unresolvable).compareLocaleKeys(base, other)—{ missing, extra }leaf-path diff.findInvalidLeafValues(obj, { allowEmpty? })— empty-string and non-string leaves with their paths and types.checkLocaleParity({ base, locales, baseName? })— the one-call version: every dictionary diffed against the base, plus leaf-value validation (the base included, reported underbaseName);okis true only when everything matches.
A locale-completeness test in vitest:
import { describe, it, expect } from 'vitest';
import { checkLocaleParity, compareLocaleKeys } from '@watchgold/i18n-kit';
import en from '../locales/en';
import zh from '../locales/zh';
import ar from '../locales/ar';
const bundles = { zh, ar };
describe('locale completeness', () => {
// One assertion covering structure and values; failures print the full report.
it('every locale mirrors the en key structure with valid values', () => {
const result = checkLocaleParity({ base: en, locales: bundles, baseName: 'en' });
expect(result.problems).toEqual([]);
expect(result.ok).toBe(true);
});
// Or per-locale cases for granular failure output:
for (const [name, bundle] of Object.entries(bundles)) {
it(`${name} has exactly the keys of en`, () => {
const { missing, extra } = compareLocaleKeys(en, bundle);
expect(missing, `${name} is missing keys: ${missing.join(', ')}`).toEqual([]);
expect(extra, `${name} has extra keys: ${extra.join(', ')}`).toEqual([]);
});
}
});The same check as a CI script:
import { checkLocaleParity } from '@watchgold/i18n-kit';
const result = checkLocaleParity({ base: en, locales: { zh, ar }, baseName: 'en' });
if (!result.ok) {
console.error(JSON.stringify(result.problems, null, 2));
process.exit(1);
}Practical policy that pairs well with the checker: when a key is added to the base locale, add it to every other locale in the same commit — starting a translation as the base-language string is fine (the checker flags missing keys, not identical strings), but structural drift is never fine.
LocaleSet API
| Member | Type | Notes |
| --- | --- | --- |
| locales | readonly L[] | Configuration order — also the emission order of hreflang maps. |
| defaultLocale | L | Validated member of locales. |
| isLocale(value) | value is L | Type guard for URL segments, headers, cookies. |
| isRtl(locale) | boolean | false for unknown locales. |
| dir(locale) | 'rtl' \| 'ltr' | For <html dir>; 'ltr' for unknown locales. |
| htmlLang(locale) | string | BCP 47 form for <html lang> and hreflang keys; falls back to the code. |
| ogLocale(locale) | string | og:locale form; falls back to the code. |
| label(locale) | string | Native-name picker label; falls back to the code. |
RTL tip: prefer logical CSS properties (margin-inline-start, padding-inline-end, text-align: start) so layouts mirror correctly under dir="rtl" without per-locale styles.
Extracted from the production codebase of WatchGold, a precious-metals market-data platform.
MIT
