@ingram-tech/nk-i18n 0.3.4 → 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 +85 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +79 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +101 -0
- package/dist/next.js.map +1 -0
- package/dist/routing.d.ts +127 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +128 -0
- package/dist/routing.js.map +1 -0
- package/package.json +14 -5
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
|
-
|
|
95
|
-
|
|
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`,
|
|
119
|
-
`
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
package/dist/next.js.map
ADDED
|
@@ -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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"}
|
package/dist/routing.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "Type-safe, English-as-key i18n for Ingram Next.js sites — ICU translator, scopes, locale config,
|
|
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": {
|
|
@@ -34,18 +38,23 @@
|
|
|
34
38
|
"intl-messageformat": "^11.2.14"
|
|
35
39
|
},
|
|
36
40
|
"devDependencies": {
|
|
37
|
-
"@ingram-tech/nk-dev": "0.11.
|
|
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
|
}
|