@ingram-tech/nk-i18n 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,8 +91,9 @@ import { useLocale } from "@ingram-tech/nk-i18n/client";
91
91
  const locale = useLocale<Locale>();
92
92
  ```
93
93
 
94
- Resolve the locale on the server however the site routes. `negotiateAcceptLanguage`
95
- handles the `Accept-Language` step:
94
+ For a site that encodes the locale in the URL, use **locale routing** (next
95
+ section) rather than hand-rolling this. Otherwise, resolve however the site
96
+ routes; `negotiateAcceptLanguage` handles the `Accept-Language` step:
96
97
 
97
98
  ```ts
98
99
  import { cookies, headers } from "next/headers";
@@ -111,11 +112,91 @@ export const resolveLocale = cache(async (): Promise<Locale> => {
111
112
  });
112
113
  ```
113
114
 
115
+ ## Locale URL routing
116
+
117
+ One definition of how a locale is encoded in a URL, shared by the code that
118
+ **serves** a language and the code that **advertises** it to search engines. When
119
+ those two drift — the classic being middleware that redirects away the very
120
+ `?hl=` URLs hreflang points at — the site tells Google the French page lives at
121
+ an address that doesn't serve French, and Google drops the language. Nothing
122
+ catches that, because neither half can see the other.
123
+
124
+ ```ts
125
+ // lib/i18n/routing.ts
126
+ import { defineLocaleRouting } from "@ingram-tech/nk-i18n";
127
+
128
+ export const routing = defineLocaleRouting({
129
+ baseUrl: "https://example.com",
130
+ locales: ["en", "fr", "nl"],
131
+ defaultLocale: "en",
132
+ // strategy: "query" (default) → /pricing?hl=fr, bare path is x-default
133
+ // strategy: "prefix" → /fr/pricing, default locale stays bare
134
+ countryLocales: { FR: "fr", NL: "nl" }, // omit BE: geography can't decide
135
+ });
136
+ ```
137
+
138
+ **The rule: a URL that names a locale serves that locale, with a 200, to
139
+ everybody.** Never redirect it.
140
+
141
+ ```ts
142
+ // proxy.ts — forward, never redirect
143
+ import { forwardUrlLocale } from "@ingram-tech/nk-i18n/next";
144
+
145
+ export function proxy(request: NextRequest) {
146
+ const requestHeaders = new Headers(request.headers);
147
+ forwardUrlLocale(routing, request.nextUrl, requestHeaders);
148
+ return NextResponse.next({ request: { headers: requestHeaders } });
149
+ }
150
+ ```
151
+
152
+ ```ts
153
+ // lib/i18n/locale.ts
154
+ import { createLocaleResolver } from "@ingram-tech/nk-i18n/next";
155
+
156
+ export const resolveLocale = cache(
157
+ createLocaleResolver(routing, { account: () => getProfile().locale }),
158
+ );
159
+ ```
160
+
161
+ The precedence is fixed and not configurable:
162
+
163
+ 1. **the URL** (`?hl=fr`) 2. account setting 3. cookie 4. `Accept-Language`
164
+ 5. country 6. `defaultLocale`
165
+
166
+ The URL beating the account setting is the load-bearing part: a shared link must
167
+ show the recipient the language it names, or every localized link the site ships
168
+ is a lie. Suppliers are lazy, so a `?hl=` request never touches the database.
169
+
170
+ For hreflang, hand the same object to nk-seo — `hreflangConfigFor` sets
171
+ `currentLocale` from the **URL**, so canonicals follow the address rather than
172
+ whatever language negotiation rendered:
173
+
174
+ ```tsx
175
+ // app/layout.tsx
176
+ import { hreflangConfigFor } from "@ingram-tech/nk-i18n/next";
177
+ import { HreflangLinks } from "@ingram-tech/nk-seo/components";
178
+
179
+ <HreflangLinks {...(await hreflangConfigFor(routing))} pathname={pathname} />;
180
+ ```
181
+
182
+ The language switcher must be real `<a href={routing.urlForLocale(path, loc)}>`
183
+ links: hreflang is an annotation, not a discovery mechanism, so a button calling
184
+ a server action gives a crawler no path to the other languages.
185
+
186
+ Prove the site serves what it advertises with `assertHreflangCluster` from
187
+ `@ingram-tech/nk-seo/verify`. Full rationale in
188
+ [`docs/i18n-routing.md`](../../docs/i18n-routing.md).
189
+
114
190
  ## Exports
115
191
 
116
192
  - `@ingram-tech/nk-i18n` (server-safe, no React): `createT`, `defineI18nScope`,
117
193
  `defineMessages`, `defineI18nConfig`, `deriveLocaleConstants`, `localeMap`,
118
- `negotiateAcceptLanguage`, and the `Messages` / `I18nScope` / `Translator` /
119
- `TranslationKey` / `I18nConfig` / `LocaleDefinition` types.
194
+ `negotiateAcceptLanguage`, `defineLocaleRouting`, `LOCALE_PRECEDENCE`,
195
+ `resolveLocaleFromSignals`, `resolveLocaleFromSuppliers`, and the `Messages` /
196
+ `I18nScope` / `Translator` / `TranslationKey` / `I18nConfig` /
197
+ `LocaleDefinition` / `LocaleRouting` / `LocaleSignals` types.
120
198
  - `@ingram-tech/nk-i18n/client` (`"use client"`): `LocaleProvider`, `useLocale`,
121
199
  `useT`.
200
+ - `@ingram-tech/nk-i18n/next` (server, needs `next`): `forwardUrlLocale`,
201
+ `getUrlLocale`, `createLocaleResolver`, `hreflangConfigFor`,
202
+ `LOCALE_URL_HEADER`.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { createT, type CreateTOptions, defineI18nScope, defineMessages, type I18nScope, type LocalizedString, type Messages, type MessageSource, type TranslationKey, type Translator, } from "./core.js";
2
2
  export { defineI18nConfig, deriveLocaleConstants, type I18nConfig, localeMap, type LocaleDefinition, type MissingKeysPolicy, } from "./config.js";
3
3
  export { negotiateAcceptLanguage } from "./negotiate.js";
4
+ export { defineLocaleRouting, LOCALE_PRECEDENCE, type LocaleRouting, type LocaleRoutingConfig, type LocaleSignal, type LocaleSignals, type LocaleStrategy, type LocaleSupplier, type LocaleSuppliers, resolveLocaleFromSignals, resolveLocaleFromSuppliers, } from "./routing.js";
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACN,OAAO,EACP,KAAK,cAAc,EACnB,eAAe,EACf,cAAc,EACd,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,QAAQ,EACb,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,UAAU,GACf,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,UAAU,EACf,SAAS,EACT,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,GACtB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACN,OAAO,EACP,KAAK,cAAc,EACnB,eAAe,EACf,cAAc,EACd,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,QAAQ,EACb,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,UAAU,GACf,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,UAAU,EACf,SAAS,EACT,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,GACtB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,EACN,mBAAmB,EACnB,iBAAiB,EACjB,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACxB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,wBAAwB,EACxB,0BAA0B,GAC1B,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -2,4 +2,5 @@
2
2
  export { createT, defineI18nScope, defineMessages, } from "./core.js";
3
3
  export { defineI18nConfig, deriveLocaleConstants, localeMap, } from "./config.js";
4
4
  export { negotiateAcceptLanguage } from "./negotiate.js";
5
+ export { defineLocaleRouting, LOCALE_PRECEDENCE, resolveLocaleFromSignals, resolveLocaleFromSuppliers, } from "./routing.js";
5
6
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,oFAAoF;AACpF,OAAO,EACN,OAAO,EAEP,eAAe,EACf,cAAc,GAOd,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,gBAAgB,EAChB,qBAAqB,EAErB,SAAS,GAGT,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,oFAAoF;AACpF,OAAO,EACN,OAAO,EAEP,eAAe,EACf,cAAc,GAOd,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,gBAAgB,EAChB,qBAAqB,EAErB,SAAS,GAGT,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,EACN,mBAAmB,EACnB,iBAAiB,EAQjB,wBAAwB,EACxB,0BAA0B,GAC1B,MAAM,cAAc,CAAC"}
package/dist/next.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { LocaleRouting, LocaleSupplier } from "./routing.js";
2
+ /**
3
+ * Request header carrying the locale the URL named, from middleware to the
4
+ * server components that render it. A request header, not a response one:
5
+ * `headers()` in a server component only sees what middleware forwarded.
6
+ */
7
+ export declare const LOCALE_URL_HEADER = "x-nk-url-locale";
8
+ /**
9
+ * Middleware: read the locale `url` names and forward it on `requestHeaders`.
10
+ * Returns it too, for callers that want to branch.
11
+ *
12
+ * Set-or-delete, never pass through: the header is ours to mint, so a client
13
+ * that sends one of its own must not reach the app.
14
+ *
15
+ * This deliberately does NOT redirect. Under the `"query"` strategy the bare
16
+ * path is a negotiating entry point and `?hl=xx` addresses are the indexable
17
+ * per-locale ones; both must return 200.
18
+ *
19
+ * export function proxy(request: NextRequest) {
20
+ * const requestHeaders = new Headers(request.headers);
21
+ * forwardUrlLocale(routing, request.nextUrl, requestHeaders);
22
+ * return NextResponse.next({ request: { headers: requestHeaders } });
23
+ * }
24
+ */
25
+ export declare function forwardUrlLocale(routing: LocaleRouting, url: URL, requestHeaders: Headers): string | undefined;
26
+ /**
27
+ * The locale the current URL names, or `undefined` when it names none — the
28
+ * bare negotiating path under the `"query"` strategy.
29
+ *
30
+ * This, not the negotiated locale, is what a canonical tag must follow. A
31
+ * canonical is a statement about an address; `/pricing` canonicalizes to
32
+ * `/pricing` even while it renders French for a French visitor.
33
+ */
34
+ export declare function getUrlLocale(routing: LocaleRouting): Promise<string | undefined>;
35
+ export interface LocaleResolverOptions {
36
+ /** Remembered-choice cookie name. Default `"locale"`. */
37
+ cookieName?: string;
38
+ /**
39
+ * The signed-in user's stored preference. Only called when the URL did not
40
+ * name a locale, so a `?hl=` request costs no database round trip.
41
+ */
42
+ account?: LocaleSupplier;
43
+ /**
44
+ * ISO-3166 alpha-2 country for the last-resort signal. Defaults to Vercel's
45
+ * `x-vercel-ip-country`. Only consulted when every stronger signal is silent,
46
+ * and only for countries present in `routing.countryLocales`.
47
+ */
48
+ country?: LocaleSupplier;
49
+ }
50
+ /**
51
+ * Build the request-scoped locale resolver. Wrap the result in React's `cache()`
52
+ * if you call it more than once per render.
53
+ *
54
+ * export const resolveLocale = cache(
55
+ * createLocaleResolver(routing, { account: () => getProfile().locale }),
56
+ * );
57
+ */
58
+ export declare function createLocaleResolver(routing: LocaleRouting, options?: LocaleResolverOptions): () => Promise<string>;
59
+ /**
60
+ * The hreflang config for the page being rendered, with `currentLocale` set from
61
+ * the URL rather than from negotiation. Spread it into `<HreflangLinks>` (from
62
+ * `@ingram-tech/nk-seo/components`) or `hreflangAlternates`:
63
+ *
64
+ * <HreflangLinks {...(await hreflangConfigFor(routing))} pathname={pathname} />
65
+ *
66
+ * Going through here is what keeps the advertised URLs and the served URLs the
67
+ * same strings, and what keeps canonicals following the address instead of the
68
+ * rendered language.
69
+ */
70
+ export declare function hreflangConfigFor(routing: LocaleRouting): Promise<{
71
+ baseUrl: string;
72
+ locales: readonly string[];
73
+ defaultLocale: string;
74
+ strategy: "query" | "prefix";
75
+ param: string;
76
+ prefixDefaultLocale: boolean;
77
+ currentLocale: string | undefined;
78
+ }>;
79
+ //# sourceMappingURL=next.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"next.d.ts","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAGlE;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,oBAAoB,CAAC;AAKnD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAC/B,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,GAAG,EACR,cAAc,EAAE,OAAO,GACrB,MAAM,GAAG,SAAS,CAQpB;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CACjC,OAAO,EAAE,aAAa,GACpB,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAG7B;AAED,MAAM,WAAW,qBAAqB;IACrC,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB;;;;OAIG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CACnC,OAAO,EAAE,aAAa,EACtB,OAAO,GAAE,qBAA0B,GACjC,MAAM,OAAO,CAAC,MAAM,CAAC,CAYvB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC;IACxE,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,mBAAmB,EAAE,OAAO,CAAC;IAC7B,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC,CAAC,CAUD"}
package/dist/next.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Next.js wiring for {@link LocaleRouting}: the middleware side that reads the
3
+ * locale a URL names, and the server-component side that resolves the locale
4
+ * for a request and builds a matching hreflang config.
5
+ *
6
+ * The rule this module exists to enforce: a URL that names a locale SERVES that
7
+ * locale, with a 200. It is never redirected away. Redirecting `?hl=fr` to the
8
+ * bare path is the bug that makes every hreflang annotation on the site point at
9
+ * a URL which does not serve the language it claims, and Google responds by
10
+ * dropping the non-default languages entirely.
11
+ */
12
+ import { cookies, headers } from "next/headers";
13
+ import { resolveLocaleFromSuppliers } from "./routing.js";
14
+ /**
15
+ * Request header carrying the locale the URL named, from middleware to the
16
+ * server components that render it. A request header, not a response one:
17
+ * `headers()` in a server component only sees what middleware forwarded.
18
+ */
19
+ export const LOCALE_URL_HEADER = "x-nk-url-locale";
20
+ /** Vercel's geo header, the default source for the country signal. */
21
+ const VERCEL_COUNTRY_HEADER = "x-vercel-ip-country";
22
+ /**
23
+ * Middleware: read the locale `url` names and forward it on `requestHeaders`.
24
+ * Returns it too, for callers that want to branch.
25
+ *
26
+ * Set-or-delete, never pass through: the header is ours to mint, so a client
27
+ * that sends one of its own must not reach the app.
28
+ *
29
+ * This deliberately does NOT redirect. Under the `"query"` strategy the bare
30
+ * path is a negotiating entry point and `?hl=xx` addresses are the indexable
31
+ * per-locale ones; both must return 200.
32
+ *
33
+ * export function proxy(request: NextRequest) {
34
+ * const requestHeaders = new Headers(request.headers);
35
+ * forwardUrlLocale(routing, request.nextUrl, requestHeaders);
36
+ * return NextResponse.next({ request: { headers: requestHeaders } });
37
+ * }
38
+ */
39
+ export function forwardUrlLocale(routing, url, requestHeaders) {
40
+ const locale = routing.localeFromUrl(url);
41
+ if (locale) {
42
+ requestHeaders.set(LOCALE_URL_HEADER, locale);
43
+ }
44
+ else {
45
+ requestHeaders.delete(LOCALE_URL_HEADER);
46
+ }
47
+ return locale;
48
+ }
49
+ /**
50
+ * The locale the current URL names, or `undefined` when it names none — the
51
+ * bare negotiating path under the `"query"` strategy.
52
+ *
53
+ * This, not the negotiated locale, is what a canonical tag must follow. A
54
+ * canonical is a statement about an address; `/pricing` canonicalizes to
55
+ * `/pricing` even while it renders French for a French visitor.
56
+ */
57
+ export async function getUrlLocale(routing) {
58
+ const value = (await headers()).get(LOCALE_URL_HEADER);
59
+ return routing.isLocale(value) && value !== null ? value : undefined;
60
+ }
61
+ /**
62
+ * Build the request-scoped locale resolver. Wrap the result in React's `cache()`
63
+ * if you call it more than once per render.
64
+ *
65
+ * export const resolveLocale = cache(
66
+ * createLocaleResolver(routing, { account: () => getProfile().locale }),
67
+ * );
68
+ */
69
+ export function createLocaleResolver(routing, options = {}) {
70
+ const { cookieName = "locale", account, country } = options;
71
+ return () => resolveLocaleFromSuppliers(routing, {
72
+ url: async () => (await headers()).get(LOCALE_URL_HEADER),
73
+ account,
74
+ cookie: async () => (await cookies()).get(cookieName)?.value,
75
+ acceptLanguage: async () => (await headers()).get("accept-language"),
76
+ country: country ?? (async () => (await headers()).get(VERCEL_COUNTRY_HEADER)),
77
+ });
78
+ }
79
+ /**
80
+ * The hreflang config for the page being rendered, with `currentLocale` set from
81
+ * the URL rather than from negotiation. Spread it into `<HreflangLinks>` (from
82
+ * `@ingram-tech/nk-seo/components`) or `hreflangAlternates`:
83
+ *
84
+ * <HreflangLinks {...(await hreflangConfigFor(routing))} pathname={pathname} />
85
+ *
86
+ * Going through here is what keeps the advertised URLs and the served URLs the
87
+ * same strings, and what keeps canonicals following the address instead of the
88
+ * rendered language.
89
+ */
90
+ export async function hreflangConfigFor(routing) {
91
+ return {
92
+ baseUrl: routing.baseUrl,
93
+ locales: routing.locales,
94
+ defaultLocale: routing.defaultLocale,
95
+ strategy: routing.strategy,
96
+ param: routing.param,
97
+ prefixDefaultLocale: routing.prefixDefaultLocale,
98
+ currentLocale: await getUrlLocale(routing),
99
+ };
100
+ }
101
+ //# sourceMappingURL=next.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"next.js","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEhD,OAAO,EAAE,0BAA0B,EAAE,MAAM,cAAc,CAAC;AAE1D;;;;GAIG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,iBAAiB,CAAC;AAEnD,sEAAsE;AACtE,MAAM,qBAAqB,GAAG,qBAAqB,CAAC;AAEpD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,gBAAgB,CAC/B,OAAsB,EACtB,GAAQ,EACR,cAAuB;IAEvB,MAAM,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;IAC1C,IAAI,MAAM,EAAE,CAAC;QACZ,cAAc,CAAC,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;IAC/C,CAAC;SAAM,CAAC;QACP,cAAc,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC;IAC1C,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CACjC,OAAsB;IAEtB,MAAM,KAAK,GAAG,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACvD,OAAO,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AACtE,CAAC;AAkBD;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CACnC,OAAsB,EACtB,OAAO,GAA0B,EAAE;IAEnC,MAAM,EAAE,UAAU,GAAG,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAE5D,OAAO,GAAG,EAAE,CACX,0BAA0B,CAAC,OAAO,EAAE;QACnC,GAAG,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,iBAAiB,CAAC;QACzD,OAAO;QACP,MAAM,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,KAAK;QAC5D,cAAc,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,iBAAiB,CAAC;QACpE,OAAO,EACN,OAAO,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;KACtE,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,OAAsB;IAS7D,OAAO;QACN,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,aAAa,EAAE,OAAO,CAAC,aAAa;QACpC,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,mBAAmB,EAAE,OAAO,CAAC,mBAAmB;QAChD,aAAa,EAAE,MAAM,YAAY,CAAC,OAAO,CAAC;KAC1C,CAAC;AACH,CAAC"}
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Locale routing: the single definition of how a locale is encoded in a URL,
3
+ * and the fixed precedence by which a request's locale is decided.
4
+ *
5
+ * This exists because the two halves of a multilingual site are easy to drift
6
+ * apart. One half advertises URLs to search engines (`@ingram-tech/nk-seo`'s
7
+ * hreflang alternates); the other half decides which language a request gets.
8
+ * When they disagree — the classic failure is middleware that redirects away
9
+ * the very `?hl=` URLs hreflang points at — the site tells Google the French
10
+ * page lives at an address that does not serve French, and Google discards the
11
+ * whole cluster. Nothing catches that, because neither half can see the other.
12
+ *
13
+ * A {@link LocaleRouting} is deliberately shaped so it can be handed straight
14
+ * to `hreflangAlternates` as its config: one object owns the locale list, the
15
+ * default, the strategy and the param name, so the advertised URL and the
16
+ * served URL are the same string by construction.
17
+ */
18
+ /**
19
+ * How a locale is encoded in a URL.
20
+ *
21
+ * - `"query"`: every locale gets `?<param>=<locale>`, and the bare path is a
22
+ * negotiating entry point that belongs to no locale (it is `x-default`).
23
+ * - `"prefix"`: the default locale lives at the bare path and the rest get
24
+ * `/<locale>/…`, unless {@link LocaleRoutingConfig.prefixDefaultLocale}.
25
+ */
26
+ export type LocaleStrategy = "query" | "prefix";
27
+ export interface LocaleRoutingConfig {
28
+ /** Absolute site origin, e.g. "https://acme.example". */
29
+ baseUrl: string;
30
+ /** Every supported locale, e.g. `["en", "fr", "nl"]`. */
31
+ locales: readonly string[];
32
+ /**
33
+ * The locale served when no signal says otherwise. Under `"query"` it is
34
+ * NOT the owner of the bare path: the bare path negotiates and `x-default`
35
+ * points at it, while the default locale gets its own `?<param>=` address
36
+ * like every other locale.
37
+ */
38
+ defaultLocale: string;
39
+ /** Default `"query"`. */
40
+ strategy?: LocaleStrategy;
41
+ /** Query-param name for the `"query"` strategy. Default `"hl"`. */
42
+ param?: string;
43
+ /** `"prefix"` only: prefix the default locale too (`/en/about`). */
44
+ prefixDefaultLocale?: boolean;
45
+ /**
46
+ * ISO-3166 alpha-2 country → locale, for the last-resort country signal.
47
+ * Omit a country whose language is genuinely ambiguous (Belgium is the
48
+ * obvious one: geography tells you nothing about whether a visitor reads
49
+ * French or Dutch) so it falls through to {@link defaultLocale} instead of
50
+ * guessing. Countries absent from the map are ignored.
51
+ */
52
+ countryLocales?: Readonly<Record<string, string>>;
53
+ }
54
+ /**
55
+ * The signals a locale can be decided from, in no particular order — the order
56
+ * is {@link resolveLocaleFromSignals}'s to own, not the caller's.
57
+ */
58
+ export interface LocaleSignals {
59
+ /** The locale the URL itself names (the `?hl=` value, or a path prefix). */
60
+ url?: string | null | undefined;
61
+ /** The signed-in user's stored preference. */
62
+ account?: string | null | undefined;
63
+ /** The remembered-choice cookie. */
64
+ cookie?: string | null | undefined;
65
+ /** Raw `Accept-Language` header value; negotiated, not matched literally. */
66
+ acceptLanguage?: string | null | undefined;
67
+ /** ISO-3166 alpha-2 country code, mapped through `countryLocales`. */
68
+ country?: string | null | undefined;
69
+ }
70
+ export interface LocaleRouting extends Required<Omit<LocaleRoutingConfig, "countryLocales">> {
71
+ countryLocales: Readonly<Record<string, string>>;
72
+ /** Narrow an arbitrary value (cookie, header, DB column) to a locale. */
73
+ isLocale: (value: unknown) => boolean;
74
+ /**
75
+ * The locale this URL *names*, or `undefined` when it names none (the bare
76
+ * negotiating path under `"query"`). This is the value canonical tags must
77
+ * follow: a canonical is a statement about an address, not about whichever
78
+ * language negotiation happened to render.
79
+ */
80
+ localeFromUrl: (url: URL | string) => string | undefined;
81
+ /** The absolute address that always serves `locale`. */
82
+ urlForLocale: (pathname: string, locale: string) => string;
83
+ /** The absolute bare address — `x-default` under `"query"`. */
84
+ bareUrl: (pathname: string) => string;
85
+ /** Apply the fixed precedence to a set of signals. */
86
+ resolve: (signals: LocaleSignals) => string;
87
+ }
88
+ /**
89
+ * The one order every Ingram site decides a locale in:
90
+ *
91
+ * 1. the URL (`?hl=fr`) — an address that names a language always wins,
92
+ * including over a signed-in user's stored preference. A shared link must
93
+ * show the recipient the language it names, or the link is a lie and the
94
+ * hreflang annotation pointing at it is too.
95
+ * 2. the account's stored preference
96
+ * 3. the remembered-choice cookie
97
+ * 4. `Accept-Language`
98
+ * 5. country, via `countryLocales`
99
+ *
100
+ * ...then `defaultLocale`. The order is not configurable, and it is declared
101
+ * exactly once so the eager and lazy resolvers cannot disagree. It is the whole
102
+ * reason this lives in nextkit: a site that puts the cookie above the URL
103
+ * silently breaks every localized link it ships, and the breakage is invisible
104
+ * until the search traffic is gone.
105
+ */
106
+ export declare const LOCALE_PRECEDENCE: readonly ["url", "account", "cookie", "acceptLanguage", "country"];
107
+ export type LocaleSignal = (typeof LOCALE_PRECEDENCE)[number];
108
+ type RoutingSlice = Pick<LocaleRouting, "locales" | "defaultLocale" | "countryLocales" | "isLocale">;
109
+ /** Apply {@link LOCALE_PRECEDENCE} to already-gathered signal values. */
110
+ export declare function resolveLocaleFromSignals(routing: RoutingSlice, signals: LocaleSignals): string;
111
+ /** A signal source; may be async, and is only called if the chain reaches it. */
112
+ export type LocaleSupplier = () => string | null | undefined | Promise<string | null | undefined>;
113
+ export type LocaleSuppliers = Partial<Record<LocaleSignal, LocaleSupplier>>;
114
+ /**
115
+ * Apply {@link LOCALE_PRECEDENCE} to lazily-evaluated sources, stopping at the
116
+ * first that yields a locale. Suppliers later in the chain are never called, so
117
+ * a URL that names its language costs no database round trip.
118
+ */
119
+ export declare function resolveLocaleFromSuppliers(routing: RoutingSlice, suppliers: LocaleSuppliers): Promise<string>;
120
+ /**
121
+ * Build the routing definition. Hand the result to BOTH your locale resolver
122
+ * and your hreflang config — a `LocaleRouting` is a valid `HreflangConfig`, so
123
+ * the URL you advertise and the URL you serve cannot drift.
124
+ */
125
+ export declare function defineLocaleRouting(config: LocaleRoutingConfig): LocaleRouting;
126
+ export {};
127
+ //# sourceMappingURL=routing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routing.d.ts","sourceRoot":"","sources":["../src/routing.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,128 @@
1
+ import { negotiateAcceptLanguage } from "./negotiate.js";
2
+ /** Resolve `path` against `baseUrl`, refusing anything that escapes the origin. */
3
+ const absolute = (path, baseUrl) => {
4
+ const base = new URL(baseUrl);
5
+ const resolved = new URL(path, base);
6
+ if (resolved.origin !== base.origin) {
7
+ throw new Error(`@ingram-tech/nk-i18n: "${path}" resolves outside the site origin ${base.origin}`);
8
+ }
9
+ return resolved.toString();
10
+ };
11
+ /**
12
+ * The one order every Ingram site decides a locale in:
13
+ *
14
+ * 1. the URL (`?hl=fr`) — an address that names a language always wins,
15
+ * including over a signed-in user's stored preference. A shared link must
16
+ * show the recipient the language it names, or the link is a lie and the
17
+ * hreflang annotation pointing at it is too.
18
+ * 2. the account's stored preference
19
+ * 3. the remembered-choice cookie
20
+ * 4. `Accept-Language`
21
+ * 5. country, via `countryLocales`
22
+ *
23
+ * ...then `defaultLocale`. The order is not configurable, and it is declared
24
+ * exactly once so the eager and lazy resolvers cannot disagree. It is the whole
25
+ * reason this lives in nextkit: a site that puts the cookie above the URL
26
+ * silently breaks every localized link it ships, and the breakage is invisible
27
+ * until the search traffic is gone.
28
+ */
29
+ export const LOCALE_PRECEDENCE = [
30
+ "url",
31
+ "account",
32
+ "cookie",
33
+ "acceptLanguage",
34
+ "country",
35
+ ];
36
+ /**
37
+ * Turn one raw signal value into a locale. Most signals are already a locale
38
+ * code and only need narrowing; `acceptLanguage` is a header to negotiate and
39
+ * `country` is an ISO code to look up.
40
+ */
41
+ const normalize = (signal, raw, routing) => {
42
+ if (raw === null || raw === undefined || raw === "")
43
+ return undefined;
44
+ if (signal === "acceptLanguage") {
45
+ const negotiated = negotiateAcceptLanguage(raw, routing.locales);
46
+ return routing.isLocale(negotiated) ? negotiated : undefined;
47
+ }
48
+ if (signal === "country") {
49
+ const mapped = routing.countryLocales[raw.toUpperCase()];
50
+ return routing.isLocale(mapped) ? mapped : undefined;
51
+ }
52
+ return routing.isLocale(raw) ? raw : undefined;
53
+ };
54
+ /** Apply {@link LOCALE_PRECEDENCE} to already-gathered signal values. */
55
+ export function resolveLocaleFromSignals(routing, signals) {
56
+ for (const signal of LOCALE_PRECEDENCE) {
57
+ const locale = normalize(signal, signals[signal], routing);
58
+ if (locale)
59
+ return locale;
60
+ }
61
+ return routing.defaultLocale;
62
+ }
63
+ /**
64
+ * Apply {@link LOCALE_PRECEDENCE} to lazily-evaluated sources, stopping at the
65
+ * first that yields a locale. Suppliers later in the chain are never called, so
66
+ * a URL that names its language costs no database round trip.
67
+ */
68
+ export async function resolveLocaleFromSuppliers(routing, suppliers) {
69
+ for (const signal of LOCALE_PRECEDENCE) {
70
+ const supplier = suppliers[signal];
71
+ if (!supplier)
72
+ continue;
73
+ const locale = normalize(signal, await supplier(), routing);
74
+ if (locale)
75
+ return locale;
76
+ }
77
+ return routing.defaultLocale;
78
+ }
79
+ /**
80
+ * Build the routing definition. Hand the result to BOTH your locale resolver
81
+ * and your hreflang config — a `LocaleRouting` is a valid `HreflangConfig`, so
82
+ * the URL you advertise and the URL you serve cannot drift.
83
+ */
84
+ export function defineLocaleRouting(config) {
85
+ const { baseUrl, locales, defaultLocale, strategy = "query", param = "hl", prefixDefaultLocale = false, countryLocales = {}, } = config;
86
+ if (!locales.includes(defaultLocale)) {
87
+ throw new Error(`@ingram-tech/nk-i18n: defaultLocale "${defaultLocale}" is not in locales [${locales.join(", ")}].`);
88
+ }
89
+ const isLocale = (value) => typeof value === "string" && locales.includes(value);
90
+ const bareUrl = (pathname) => absolute(pathname, baseUrl);
91
+ const urlForLocale = (pathname, locale) => {
92
+ if (strategy === "prefix") {
93
+ if (locale === defaultLocale && !prefixDefaultLocale)
94
+ return bareUrl(pathname);
95
+ return absolute(`/${locale}${pathname === "/" ? "" : pathname}`, baseUrl);
96
+ }
97
+ const bare = bareUrl(pathname);
98
+ return `${bare}${bare.includes("?") ? "&" : "?"}${param}=${locale}`;
99
+ };
100
+ const localeFromUrl = (url) => {
101
+ const parsed = typeof url === "string" ? new URL(url, baseUrl) : url;
102
+ if (strategy === "prefix") {
103
+ const found = locales.find((locale) => parsed.pathname === `/${locale}` ||
104
+ parsed.pathname.startsWith(`/${locale}/`));
105
+ if (found)
106
+ return found;
107
+ return prefixDefaultLocale ? undefined : defaultLocale;
108
+ }
109
+ const value = parsed.searchParams.get(param);
110
+ return isLocale(value) && value !== null ? value : undefined;
111
+ };
112
+ const routing = {
113
+ baseUrl,
114
+ locales,
115
+ defaultLocale,
116
+ strategy,
117
+ param,
118
+ prefixDefaultLocale,
119
+ countryLocales,
120
+ isLocale,
121
+ localeFromUrl,
122
+ urlForLocale,
123
+ bareUrl,
124
+ resolve: (signals) => resolveLocaleFromSignals(routing, signals),
125
+ };
126
+ return routing;
127
+ }
128
+ //# sourceMappingURL=routing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routing.js","sourceRoot":"","sources":["../src/routing.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-i18n",
3
- "version": "0.3.3",
4
- "description": "Type-safe, English-as-key i18n for Ingram Next.js sites — ICU translator, scopes, locale config, and Accept-Language negotiation.",
3
+ "version": "0.4.0",
4
+ "description": "Type-safe, English-as-key i18n for Ingram Next.js sites — ICU translator, scopes, locale config, Accept-Language negotiation, and locale URL routing.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "repository": {
@@ -23,6 +23,10 @@
23
23
  "./client": {
24
24
  "types": "./dist/client.d.ts",
25
25
  "import": "./dist/client.js"
26
+ },
27
+ "./next": {
28
+ "types": "./dist/next.d.ts",
29
+ "import": "./dist/next.js"
26
30
  }
27
31
  },
28
32
  "scripts": {
@@ -31,21 +35,26 @@
31
35
  "test": "vitest run"
32
36
  },
33
37
  "dependencies": {
34
- "intl-messageformat": "^11.2.13"
38
+ "intl-messageformat": "^11.2.14"
35
39
  },
36
40
  "devDependencies": {
37
- "@ingram-tech/nk-dev": "0.10.0",
41
+ "@ingram-tech/nk-dev": "0.11.1",
38
42
  "@types/react": "^19.2.18",
39
43
  "react": "^19.2.8",
40
44
  "typescript": "^7.0.2",
41
- "vitest": "^4.1.10"
45
+ "vitest": "^4.1.10",
46
+ "next": "^16.3.1"
42
47
  },
43
48
  "peerDependencies": {
44
- "react": "^18.0.0 || ^19.0.0"
49
+ "react": "^18.0.0 || ^19.0.0",
50
+ "next": ">=14.0.0"
45
51
  },
46
52
  "peerDependenciesMeta": {
47
53
  "react": {
48
54
  "optional": true
55
+ },
56
+ "next": {
57
+ "optional": true
49
58
  }
50
59
  }
51
60
  }