Skip to content

i18n: locale URL routing, shared between nk-i18n and nk-seo - #40

Merged
Jerome Leclanche (jleclanche) merged 2 commits into
mainfrom
i18n-locale-routing
Aug 23, 2026
Merged

i18n: locale URL routing, shared between nk-i18n and nk-seo#40
Jerome Leclanche (jleclanche) merged 2 commits into
mainfrom
i18n-locale-routing

Conversation

@jleclanche

Copy link
Copy Markdown
Member

Why

A multilingual site has two halves that must agree: the code that serves a
language, and the code that advertises it to search engines. Nothing made
them agree, so they drifted. The live failure on financica.app: the layout
emitted hreflang pointing at ?hl=fr, and middleware 307'd every ?hl= URL
back to the bare path. The site told Google the French page lived at an address
that refused to serve French, so French and Dutch were absent from the index
entirely. Search Console showed it only as 137 ordinary "Page with redirect".

nk-i18n

  • defineLocaleRouting — one definition of how a locale is encoded in a URL,
    shaped so it can be handed straight to nk-seo's hreflangAlternates.
  • LOCALE_PRECEDENCE — the order, declared once and not configurable: URL,
    account, cookie, Accept-Language, country, default. The URL beating the
    account setting is load-bearing: a shared ?hl=fr link must show the
    recipient French, or every localized link the site ships is a lie and so is
    the annotation pointing at it. Eager and lazy resolvers walk the same list;
    a test asserts they agree on every prefix of the chain.
  • /nextforwardUrlLocale (forwards, never redirects), getUrlLocale,
    createLocaleResolver, hreflangConfigFor.

countryLocales is a map, so omitting Belgium is how you express "geography
cannot decide between French and Dutch here" rather than a buried special case.

nk-seo

  • Canonical fix. hreflangAlternates treated "default locale" and "bare
    path" as the same thing, which only holds for the prefix strategy. Under
    "query" every locale has its own ?hl= address and the bare path is the
    negotiating x-default, so ?hl=en canonicalized away to the bare path and
    deleted the default locale from its own cluster. Prefix behaviour unchanged.
  • /verify. hreflang is a set of promises about other URLs and nothing
    local can tell you whether they hold. assertHreflangCluster fetches every
    advertised URL and fails on a redirect, a non-200, a canonical pointing
    elsewhere, a missing or duplicated canonical, a non-reciprocal cluster, or an
    <html lang> contradicting its hreflang.

The integration is enforced

verify.test.ts asserts at compile time that a LocaleRouting is a valid
HreflangConfig. Verified non-vacuous: adding a third value to nk-i18n's
strategy union fails the build in nk-seo with
Type 'LocaleRouting' is not assignable to type 'HreflangConfig'.

Rationale and the do-nots are in docs/i18n-routing.md.

🤖 Generated with Claude Code

One definition of how a locale is encoded in a URL, shared by the code that
serves a language and the code that advertises it. When those drift the site
annotates URLs that don't serve what they claim, and the languages leave the
index silently.

The precedence (URL, account, cookie, Accept-Language, country) is declared once
and is not configurable: a shared ?hl=fr link must show the recipient French, or
every localized link the site ships is a lie. Suppliers are lazy, so a ?hl=
request costs no database round trip.

/next wires it to middleware and server components. It never redirects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hreflangAlternates treated "default locale" and "bare path" as the same thing,
which only holds for the prefix strategy. Under query every locale has its own
?hl= address and the bare path is the negotiating x-default, so ?hl=en
canonicalized away to the bare path and deleted the default locale from its own
cluster.

hreflang is a set of promises about other URLs and nothing local can tell you
whether they hold: a site can emit a flawless cluster while middleware redirects
every URL in it away. /verify fetches them and fails on redirects, non-200s,
canonicals pointing elsewhere, duplicate canonicals, non-reciprocal clusters,
and an html lang contradicting its hreflang.

A LocaleRouting is a valid HreflangConfig, asserted at compile time so the two
packages cannot drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 23, 2026 20:04
@jleclanche
Jerome Leclanche (jleclanche) merged commit 8503c09 into main Aug 23, 2026
2 checks passed
@jleclanche
Jerome Leclanche (jleclanche) deleted the i18n-locale-routing branch August 23, 2026 20:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a shared, single-source locale URL routing model in @ingram-tech/nk-i18n and aligns @ingram-tech/nk-seo hreflang/canonical behavior with it, plus adds a runtime verifier to catch “advertise vs serve” drift (redirecting locale URLs, wrong canonicals, non-reciprocal clusters, etc.).

Changes:

  • Add defineLocaleRouting + fixed LOCALE_PRECEDENCE and Next.js wiring (/next) to keep locale resolution and URL encoding consistent.
  • Fix hreflangAlternates canonical logic for the "query" strategy (default locale must canonicalize to its own ?hl= URL, not the bare path).
  • Add @ingram-tech/nk-seo/verify to fetch and validate advertised hreflang clusters at runtime / in CI.

Reviewed changes

Copilot reviewed 18 out of 19 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/nk-seo/src/verify.ts Adds runtime hreflang cluster verification and error aggregation utilities.
packages/nk-seo/src/verify.test.ts Adds tests covering redirects, canonicals, reciprocity, and <html lang>.
packages/nk-seo/src/alternates.ts Fixes canonical computation semantics (address-based) for "query" vs "prefix".
packages/nk-seo/src/alternates.test.ts Adds regression tests for canonical behavior across strategies.
packages/nk-seo/README.md Documents address-vs-language canonical rules and new /verify API.
packages/nk-seo/package.json Exposes new ./verify entrypoint; adds dev dependency for integration test.
packages/nk-i18n/tsconfig.json Adjusts module/moduleResolution for Next-related typing/build compatibility.
packages/nk-i18n/src/routing.ts Implements locale URL routing + eager/lazy precedence-based resolvers.
packages/nk-i18n/src/routing.test.ts Tests URL encoding/decoding and precedence ordering + laziness properties.
packages/nk-i18n/src/next.ts Adds Next.js middleware/server-component wiring and hreflang config adapter.
packages/nk-i18n/src/index.ts Exports new routing APIs from package root.
packages/nk-i18n/README.md Documents locale URL routing and correct canonical/hreflang wiring.
packages/nk-i18n/package.json Adds ./next export and declares Next as optional peer.
packages/nk-dev/guide.md Updates package guide to mention locale URL routing in nk-i18n.
docs/README.md Adds new i18n routing doc to docs index.
docs/i18n-routing.md New design/usage document describing routing, canonicals, and verification.
bun.lock Records workspace dependency changes (Next / nk-i18n dev deps).
.changeset/locale-url-routing.md Changeset for nk-i18n minor release (locale URL routing).
.changeset/hreflang-canonical-query.md Changeset for nk-seo minor release (canonical fix + verify).
Suppressed comments (1)

packages/nk-seo/src/verify.ts:153

  • Alternate link parsing has the same rel === "alternate" issue as canonical parsing: rel is token-based, and extra rel tokens will prevent alternates from being detected. Also, alternate hrefs can be relative; comparing them without normalizing to absolute can incorrectly flag a cluster as non-reciprocal.
			const advertised = new Set(
				tagsNamed(html, "link")
					.filter(
						(attrs) =>
							attrs.rel?.toLowerCase() === "alternate" && attrs.hreflang,

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +132 to +147
const canonicals = tagsNamed(html, "link")
.filter((attrs) => attrs.rel?.toLowerCase() === "canonical")
.map((attrs) => attrs.href)
.filter((href): href is string => href !== undefined);
if (canonicals.length === 0) {
add("no <link rel=canonical>");
} else if (canonicals.length > 1) {
add(
`${canonicals.length} <link rel=canonical> tags; Google ignores all of them`,
);
} else if (canonicals[0] !== url) {
add(
`canonical points at ${canonicals[0]}, not itself; ` +
"a variant that canonicalizes elsewhere is discarded",
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants