@djangocfg/nextjs 2.1.626 → 2.1.628

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.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Route handlers for Next's icon file conventions, from one glyph and one hue.
3
+ *
4
+ * // app/icon.tsx
5
+ * import * as workflow from 'lucide-react/icons/workflow';
6
+ * import { createAppIcon } from '@djangocfg/nextjs/app-icon/next';
7
+ * const icon = createAppIcon({ glyph: workflow, hue: 55 });
8
+ * export const generateImageMetadata = icon.generateImageMetadata;
9
+ * export default icon.icon;
10
+ *
11
+ * // app/apple-icon.tsx
12
+ * export const size = { width: 180, height: 180 };
13
+ * export const contentType = 'image/png';
14
+ * export default icon.appleIcon; // same createAppIcon call, or call it again
15
+ *
16
+ * Served at `/icon/tab` (64, rounded), `/icon/192`, `/icon/384`, `/icon/512` (square) and `/apple-icon` (180, square).
17
+ */
18
+
19
+ import { ImageResponse } from 'next/og';
20
+
21
+ import { appIconElement, type AppIconGlyph } from './element';
22
+
23
+ export const appIconSizes = { tab: 64, '192': 192, '384': 384, '512': 512 } as const;
24
+ export const appleIconSize = { width: 180, height: 180 } as const;
25
+
26
+ export function createAppIcon({ glyph, hue }: { glyph: AppIconGlyph; hue: number }) {
27
+ return {
28
+ generateImageMetadata() {
29
+ return Object.entries(appIconSizes).map(([id, side]) => ({
30
+ id,
31
+ size: { width: side, height: side },
32
+ contentType: 'image/png',
33
+ }));
34
+ },
35
+ async icon({ id }: { id: Promise<string | number> }) {
36
+ const key = String(await id) as keyof typeof appIconSizes;
37
+ const side = appIconSizes[key] ?? appIconSizes.tab;
38
+ return new ImageResponse(appIconElement({ glyph, hue, size: side, shape: key === 'tab' ? 'rounded' : 'square' }), {
39
+ width: side,
40
+ height: side,
41
+ });
42
+ },
43
+ appleIcon() {
44
+ return new ImageResponse(appIconElement({ glyph, hue, size: appleIconSize.width, shape: 'square' }), appleIconSize);
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,6 @@
1
+ /** The manifest's `icons` for an app that serves `createAppIcon` at `app/icon.tsx`. Pass it to `createManifest({ icons })`. */
2
+ export const appIconPaths = {
3
+ logo192: '/icon/192',
4
+ logo384: '/icon/384',
5
+ logo512: '/icon/512',
6
+ } as const;
@@ -28,6 +28,7 @@ import {
28
28
  import { usePathname } from './navigation';
29
29
  import { I18nProvider, NextIntlProvider, useDeclaredLocales } from './provider';
30
30
  import { routing } from './routing';
31
+ import { expireHostOnlyLocaleCookie, localeCookieHeader, sharedCookieDomain } from './shared-locale';
31
32
 
32
33
  // Re-export providers
33
34
  export { I18nProvider, NextIntlProvider };
@@ -198,10 +199,12 @@ export function useChangeLocale() {
198
199
  const pathname = usePathname();
199
200
 
200
201
  return (locale: LocaleCode) => {
201
- // Set NEXT_LOCALE cookie so middleware remembers the choice
202
- const date = new Date();
203
- date.setFullYear(date.getFullYear() + 1);
204
- document.cookie = `NEXT_LOCALE=${locale}; expires=${date.toUTCString()}; path=/`;
202
+ // Set NEXT_LOCALE so middleware remembers the choice, on the registrable domain so every host of the family
203
+ // reads it (see ./shared-locale); the host-only twin is expired so it cannot shadow the shared one.
204
+ const secure = window.location.protocol === 'https:';
205
+ const domain = sharedCookieDomain(window.location.hostname, process.env.NEXT_PUBLIC_COOKIE_DOMAIN);
206
+ document.cookie = localeCookieHeader(locale, domain, secure);
207
+ if (domain) document.cookie = expireHostOnlyLocaleCookie(secure);
205
208
 
206
209
  // Hard navigation to force full page reload with new locale
207
210
  // (ensures server components re-render with updated translations)
package/src/i18n/index.ts CHANGED
@@ -48,3 +48,5 @@ export type {
48
48
  export type { LinkProps } from './navigation';
49
49
  export type { RequestConfigOptions } from './request';
50
50
  export type { LocaleSwitcherProps } from './components';
51
+
52
+ export { sharedCookieDomain, localeCookieHeader, expireHostOnlyLocaleCookie, LOCALE_COOKIE } from './shared-locale';
@@ -0,0 +1,22 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { NextRequest, NextResponse } from 'next/server';
4
+ import { shareLocaleCookie } from './proxy';
5
+
6
+ test('rewrites the cookie onto the registrable domain', () => {
7
+ const request = new NextRequest('https://hub.example.com/ru', { headers: { 'x-forwarded-proto': 'https' } });
8
+ const response = NextResponse.next();
9
+ response.cookies.set('NEXT_LOCALE', 'ru', { path: '/' });
10
+ const out = shareLocaleCookie(request, response);
11
+ const lines = out.headers.getSetCookie();
12
+ assert.ok(lines.some((l) => l.startsWith('NEXT_LOCALE=ru') && l.includes('Domain=example.com')));
13
+ assert.ok(lines.some((l) => l.startsWith('NEXT_LOCALE=;') && l.includes('Max-Age=0') && !l.includes('Domain')));
14
+ });
15
+
16
+ test('localhost is left alone', () => {
17
+ const request = new NextRequest('http://localhost:3211/ru');
18
+ const response = NextResponse.next();
19
+ response.cookies.set('NEXT_LOCALE', 'ru', { path: '/' });
20
+ const out = shareLocaleCookie(request, response);
21
+ assert.equal(out.headers.getSetCookie().length, 1);
22
+ });
package/src/i18n/proxy.ts CHANGED
@@ -20,8 +20,9 @@
20
20
  import createIntlMiddleware from 'next-intl/middleware';
21
21
 
22
22
  import { createRouting, routing } from './routing';
23
+ import { expireHostOnlyLocaleCookie, LOCALE_COOKIE, LOCALE_COOKIE_MAX_AGE, sharedCookieDomain } from './shared-locale';
23
24
 
24
- import type { NextRequest } from 'next/server';
25
+ import type { NextRequest, NextResponse } from 'next/server';
25
26
 
26
27
  import type { I18nConfig } from './types';
27
28
 
@@ -29,20 +30,37 @@ import type { I18nConfig } from './types';
29
30
  // Proxy Factory
30
31
  // ─────────────────────────────────────────────────────────────────────────────
31
32
 
33
+ /**
34
+ * next-intl writes `NEXT_LOCALE` host-only. Rewrite it onto the registrable domain, so the locale a reader chose on one host
35
+ * of the family is the locale on all of them, and expire the host-only twin that would shadow it. A host that cannot share
36
+ * (localhost, an IP) is left as next-intl wrote it. `NEXT_PUBLIC_COOKIE_DOMAIN` overrides the guess.
37
+ */
38
+ export function shareLocaleCookie(request: NextRequest, response: NextResponse): NextResponse {
39
+ const written = response.cookies.get(LOCALE_COOKIE);
40
+ if (!written) return response;
41
+ const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? request.nextUrl.hostname;
42
+ const domain = sharedCookieDomain(host, process.env.NEXT_PUBLIC_COOKIE_DOMAIN);
43
+ if (!domain) return response;
44
+ const secure = (request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol.replace(':', '')) === 'https';
45
+ response.cookies.set({ name: LOCALE_COOKIE, value: written.value, domain, path: '/', maxAge: LOCALE_COOKIE_MAX_AGE, sameSite: 'lax', secure });
46
+ response.headers.append('set-cookie', expireHostOnlyLocaleCookie(secure));
47
+ return response;
48
+ }
49
+
32
50
  /**
33
51
  * Create i18n proxy handler with custom routing configuration
34
52
  */
35
53
  export function createProxy(routingConfig?: ReturnType<typeof createRouting>) {
36
54
  const config = routingConfig ?? routing;
37
- return createIntlMiddleware(config);
55
+ const intl = createIntlMiddleware(config);
56
+ return (request: NextRequest) => shareLocaleCookie(request, intl(request) as NextResponse);
38
57
  }
39
58
 
40
59
  /**
41
60
  * Create i18n proxy from config options
42
61
  */
43
62
  export function createProxyFromConfig(config: Partial<I18nConfig>) {
44
- const routingConfig = createRouting(config);
45
- return createIntlMiddleware(routingConfig);
63
+ return createProxy(createRouting(config));
46
64
  }
47
65
 
48
66
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import { expireHostOnlyLocaleCookie, localeCookieHeader, sharedCookieDomain } from './shared-locale';
5
+
6
+ test('the registrable domain of a subdomain host', () => {
7
+ assert.equal(sharedCookieDomain('hub.example.com'), 'example.com');
8
+ assert.equal(sharedCookieDomain('team.hub.example.com:3000'), 'example.com');
9
+ assert.equal(sharedCookieDomain('example.com'), 'example.com');
10
+ });
11
+
12
+ test('two-label public suffixes keep three labels', () => {
13
+ assert.equal(sharedCookieDomain('hub.example.co.uk'), 'example.co.uk');
14
+ });
15
+
16
+ test('hosts that cannot share get no domain', () => {
17
+ assert.equal(sharedCookieDomain('localhost:3211'), null);
18
+ assert.equal(sharedCookieDomain('127.0.0.1'), null);
19
+ assert.equal(sharedCookieDomain('[::1]'), null);
20
+ });
21
+
22
+ test('the override wins', () => {
23
+ assert.equal(sharedCookieDomain('hub.example.org', '.corp.example.org'), 'corp.example.org');
24
+ assert.equal(sharedCookieDomain('localhost', 'dev.test'), 'dev.test');
25
+ });
26
+
27
+ test('cookie headers', () => {
28
+ assert.equal(
29
+ localeCookieHeader('ru', 'example.com', true),
30
+ 'NEXT_LOCALE=ru; Path=/; Max-Age=31536000; SameSite=Lax; Domain=example.com; Secure',
31
+ );
32
+ assert.equal(localeCookieHeader('ru', null, false), 'NEXT_LOCALE=ru; Path=/; Max-Age=31536000; SameSite=Lax');
33
+ assert.equal(expireHostOnlyLocaleCookie(false), 'NEXT_LOCALE=; Path=/; Max-Age=0; SameSite=Lax');
34
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * One locale across every host of one product family.
3
+ *
4
+ * `NEXT_LOCALE` is written host-only by default, so a reader who picked Russian on `hub.example.com` meets English on
5
+ * `team.example.com`. The session tokens already live on the registrable domain (`@djangocfg/api` `createStorage`,
6
+ * `cookieDomain: 'auto'`); the locale follows the same rule: the cookie gets `Domain=` the registrable domain, and the
7
+ * host-only twin is expired so it cannot shadow the shared one.
8
+ *
9
+ * Pure and framework-free, so the proxy (server) and `useChangeLocale` (browser) agree on the domain.
10
+ */
11
+
12
+ export const LOCALE_COOKIE = 'NEXT_LOCALE';
13
+ export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
14
+
15
+ // Second-level public suffixes whose registrable domain has three labels. Not the whole Public Suffix List: the common
16
+ // ones; anything else sets NEXT_PUBLIC_COOKIE_DOMAIN.
17
+ const TWO_LABEL_SUFFIXES = new Set([
18
+ 'co.uk', 'org.uk', 'ac.uk', 'com.au', 'net.au', 'co.nz', 'co.jp', 'co.kr', 'or.kr', 'com.br', 'com.tr', 'com.cn', 'com.mx',
19
+ 'co.in', 'co.za', 'com.sg', 'com.hk', 'com.tw', 'com.ar', 'com.ua', 'co.il',
20
+ ]);
21
+
22
+ /**
23
+ * The domain a cookie must carry to be sent to every subdomain, or `null` for a host that cannot share (an IP, a bare
24
+ * label such as `localhost`, or an apex with no subdomain in use: a host-only cookie already covers it).
25
+ * `override` (`NEXT_PUBLIC_COOKIE_DOMAIN`) wins when set.
26
+ */
27
+ export function sharedCookieDomain(hostname: string, override?: string | null): string | null {
28
+ const forced = override?.trim().replace(/^\./, '');
29
+ if (forced) return forced;
30
+ const host = hostname.split(':')[0]!.toLowerCase();
31
+ if (!host || /^\d+(\.\d+){3}$/.test(host) || host.includes(':')) return null;
32
+ const labels = host.split('.');
33
+ if (labels.length < 2) return null;
34
+ const keep = TWO_LABEL_SUFFIXES.has(labels.slice(-2).join('.')) ? 3 : 2;
35
+ return labels.slice(-keep).join('.');
36
+ }
37
+
38
+ /** The `Set-Cookie` value that writes the shared locale cookie. */
39
+ export function localeCookieHeader(locale: string, domain: string | null, secure: boolean): string {
40
+ return `${LOCALE_COOKIE}=${encodeURIComponent(locale)}; Path=/; Max-Age=${LOCALE_COOKIE_MAX_AGE}; SameSite=Lax${domain ? `; Domain=${domain}` : ''}${secure ? '; Secure' : ''}`;
41
+ }
42
+
43
+ /** Expires the host-only twin of the cookie (a cookie is identified by name and Domain). */
44
+ export function expireHostOnlyLocaleCookie(secure: boolean): string {
45
+ return `${LOCALE_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure ? '; Secure' : ''}`;
46
+ }