cloudflare-next-intl 0.4.0 → 0.4.2

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
@@ -193,11 +193,21 @@ auto-wires `CookieConsentProvider` (and `CookieConsentAnalytics`, if
193
193
 
194
194
  ```typescript
195
195
  // intl-config.ts
196
+ import { getCloudflareContext } from "@opennextjs/cloudflare";
197
+
196
198
  export default setIntlConfig({
197
199
  locales: ["en", "de"],
198
200
  defaultLocale: "en",
199
201
  cookieConsent: {
200
202
  privacyPolicyDate: "2026-01-01",
203
+ // Optional: gate the banner to GDPR-region visitors only. Omit both
204
+ // getters to disable country-based gating (consent always implicit).
205
+ // Pass @opennextjs/cloudflare's getCloudflareContext directly — its
206
+ // exact overloaded signature is accepted as-is, called internally
207
+ // with { async: true }.
208
+ getCloudflareContext,
209
+ // gdprCountries: [...], // defaults to EU/EEA + UK + Switzerland
210
+ // enableAnalyticsInDevMode: true, // analytics stay off in dev otherwise
201
211
  },
202
212
  });
203
213
  ```
@@ -6,7 +6,7 @@ interface LocaleContextType {
6
6
  messages: TranslationObject;
7
7
  }
8
8
  export declare const LocaleContext: import("react").Context<LocaleContextType | undefined>;
9
- export default function LocationzationClientProvider({ language, messages, initialAuthUser, skipAuthProvider, analyticsSecrets, children }: {
9
+ export default function LocationzationClientProvider({ language, messages, initialAuthUser, skipAuthProvider, analyticsSecrets, requiresConsent, children }: {
10
10
  language: string;
11
11
  messages: TranslationObject;
12
12
  initialAuthUser?: SerializedAuthUser | null;
@@ -14,6 +14,13 @@ export default function LocationzationClientProvider({ language, messages, initi
14
14
  skipAuthProvider?: boolean;
15
15
  /** Resolved server-side from `cookieConsent.secrets`/`getSecrets` when `autoWireAnalytics` isn't `false`. */
16
16
  analyticsSecrets?: CookieConsentAnalyticsSecrets;
17
+ /**
18
+ * Resolved server-side from `cookieConsent.getCountryCode`/`gdprCountries`.
19
+ * `false` means the visitor's country doesn't require the consent
20
+ * banner — `CookieConsentProvider` seeds consent as implicitly granted
21
+ * for a first-time visitor instead of `null`.
22
+ */
23
+ requiresConsent?: boolean;
17
24
  children: React.ReactNode;
18
25
  }): Component;
19
26
  export {};
@@ -15,7 +15,7 @@ export const LocaleContext = createContext(undefined);
15
15
  const AuthUserProvider = dynamic(() => import("../../firebase_auth/client/auth_user_provider"));
16
16
  const CookieConsentProvider = dynamic(() => import("../../cookie_consent/client/cookie_consent_provider"));
17
17
  const CookieConsentAnalytics = dynamic(() => import("../../cookie_consent/client/components/cookie_consent_analytics"));
18
- export default function LocationzationClientProvider({ language, messages, initialAuthUser = null, skipAuthProvider = false, analyticsSecrets, children }) {
18
+ export default function LocationzationClientProvider({ language, messages, initialAuthUser = null, skipAuthProvider = false, analyticsSecrets, requiresConsent = true, children }) {
19
19
  setLocaleCache(language);
20
20
  setMessageForLocaleCache(language, messages);
21
21
  // `LocaleContext.Provider` stays the outermost element here — the
@@ -28,7 +28,7 @@ export default function LocationzationClientProvider({ language, messages, initi
28
28
  providedChildren = _jsx(AuthUserProvider, { initialUser: initialAuthUser, children: children });
29
29
  }
30
30
  if (config.cookieConsent) {
31
- providedChildren = _jsxs(CookieConsentProvider, { children: [providedChildren, analyticsSecrets && _jsx(CookieConsentAnalytics, { secrets: analyticsSecrets })] });
31
+ providedChildren = _jsxs(CookieConsentProvider, { requiresConsent: requiresConsent, children: [providedChildren, analyticsSecrets && _jsx(CookieConsentAnalytics, { secrets: analyticsSecrets })] });
32
32
  }
33
33
  const contextValue = useMemo(() => ({ language, messages }), [language, messages]);
34
34
  return _jsx(LocaleContext.Provider, { value: contextValue, children: providedChildren });
@@ -13,6 +13,14 @@ export declare const CookieConsentContext: import("react").Context<CookieConsent
13
13
  * `privacyPolicyUpdated` becomes `true` until they call
14
14
  * `acknowledgePrivacyPolicyUpdate()`.
15
15
  *
16
+ * @param requiresConsent Resolved server-side from
17
+ * `cookieConsent.getCountryCode`/`gdprCountries` — `false` means the
18
+ * visitor's country doesn't require the banner at all, so a first-time
19
+ * visitor (no stored cookie) gets `consent` seeded to `true` instead of
20
+ * `null`, skipping the dialog and unlocking analytics immediately.
21
+ * Defaults to `true` (always show the banner) when omitted, e.g. when
22
+ * `cookieConsent.getCountryCode` isn't configured.
23
+ *
16
24
  * @example
17
25
  * ```tsx
18
26
  * <CookieConsentProvider>
@@ -22,6 +30,7 @@ export declare const CookieConsentContext: import("react").Context<CookieConsent
22
30
  * </CookieConsentProvider>
23
31
  * ```
24
32
  */
25
- export default function CookieConsentProvider({ children }: {
33
+ export default function CookieConsentProvider({ requiresConsent, children }: {
34
+ requiresConsent?: boolean;
26
35
  children: React.ReactNode;
27
36
  }): React.ReactElement;
@@ -27,6 +27,14 @@ function parseConsent(raw) {
27
27
  * `privacyPolicyUpdated` becomes `true` until they call
28
28
  * `acknowledgePrivacyPolicyUpdate()`.
29
29
  *
30
+ * @param requiresConsent Resolved server-side from
31
+ * `cookieConsent.getCountryCode`/`gdprCountries` — `false` means the
32
+ * visitor's country doesn't require the banner at all, so a first-time
33
+ * visitor (no stored cookie) gets `consent` seeded to `true` instead of
34
+ * `null`, skipping the dialog and unlocking analytics immediately.
35
+ * Defaults to `true` (always show the banner) when omitted, e.g. when
36
+ * `cookieConsent.getCountryCode` isn't configured.
37
+ *
30
38
  * @example
31
39
  * ```tsx
32
40
  * <CookieConsentProvider>
@@ -36,7 +44,7 @@ function parseConsent(raw) {
36
44
  * </CookieConsentProvider>
37
45
  * ```
38
46
  */
39
- export default function CookieConsentProvider({ children }) {
47
+ export default function CookieConsentProvider({ requiresConsent = true, children }) {
40
48
  const { consentCookieName, dateCookieName, maxAge, policyDate } = useMemo(() => {
41
49
  const cc = requireCookieConsentConfig(config.cookieConsent);
42
50
  return {
@@ -51,6 +59,10 @@ export default function CookieConsentProvider({ children }) {
51
59
  const [privacyPolicyUpdated, setPrivacyPolicyUpdated] = useState(false);
52
60
  useEffect(() => {
53
61
  const storedConsent = parseConsent(getCookie(consentCookieName));
62
+ if (storedConsent === null && !requiresConsent) {
63
+ setConsentState(true);
64
+ return;
65
+ }
54
66
  setConsentState(storedConsent);
55
67
  if (storedConsent === null || !policyDate)
56
68
  return;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import { bench, describe } from 'vitest';
2
+ import resolveRequiresConsent, { defaultGdprCountries } from './gdpr_countries';
3
+ const fakeGetCloudflareContext = ((options) => {
4
+ const context = { cf: { country: 'DE' } };
5
+ return options?.async === false ? context : Promise.resolve(context);
6
+ });
7
+ const customList = ['US', 'CA', 'MX'];
8
+ describe('resolveRequiresConsent: default GDPR list lookup', () => {
9
+ bench('country inside the list (worst case for Array.includes: near the end)', async () => {
10
+ await resolveRequiresConsent(() => 'CH', undefined, undefined);
11
+ });
12
+ bench('country outside the list', async () => {
13
+ await resolveRequiresConsent(() => 'US', undefined, undefined);
14
+ });
15
+ });
16
+ describe('resolveRequiresConsent: custom GDPR list lookup (cached Set)', () => {
17
+ bench('repeated calls with the same list reference', async () => {
18
+ await resolveRequiresConsent(() => 'US', undefined, customList);
19
+ });
20
+ });
21
+ describe('resolveRequiresConsent: gating disabled', () => {
22
+ bench('neither getter set (fast path, no lookup at all)', async () => {
23
+ await resolveRequiresConsent(undefined, undefined, undefined);
24
+ });
25
+ });
26
+ describe('resolveRequiresConsent: getCloudflareContext path', () => {
27
+ bench('resolves cf.country from an async context getter', async () => {
28
+ await resolveRequiresConsent(undefined, fakeGetCloudflareContext, undefined);
29
+ });
30
+ });
31
+ // Sanity check the list itself isn't accidentally growing unbounded — a
32
+ // regression here would also regress the Set-build cost on first use.
33
+ describe('defaultGdprCountries', () => {
34
+ bench('Set construction cost (paid once per process, not per request)', () => {
35
+ new Set(defaultGdprCountries);
36
+ });
37
+ });
@@ -0,0 +1,18 @@
1
+ import type { CookieConsentGetCloudflareContext } from '../types/types';
2
+ /**
3
+ * Default `cookieConsent.gdprCountries` — EU/EEA member states (GDPR),
4
+ * Iceland/Liechtenstein/Norway (EEA), the UK (UK-GDPR), and Switzerland
5
+ * (nFADP). ISO 3166-1 alpha-2.
6
+ */
7
+ export declare const defaultGdprCountries: readonly string[];
8
+ /**
9
+ * Resolves whether the cookie-consent banner is required for a visitor.
10
+ *
11
+ * - Neither getter set: country-based gating is off entirely — consent is
12
+ * never required (the simplest opt-in-by-default setup).
13
+ * - Either getter set: fail-safe — a country that couldn't be resolved
14
+ * still requires consent; only a resolved country OUTSIDE
15
+ * `gdprCountries` skips the banner. `getCountryCode` takes precedence
16
+ * over `getCloudflareContext` when both are set.
17
+ */
18
+ export default function resolveRequiresConsent(getCountryCode: (() => string | undefined | Promise<string | undefined>) | undefined, getCloudflareContext: CookieConsentGetCloudflareContext | undefined, gdprCountries: readonly string[] | undefined): Promise<boolean>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Default `cookieConsent.gdprCountries` — EU/EEA member states (GDPR),
3
+ * Iceland/Liechtenstein/Norway (EEA), the UK (UK-GDPR), and Switzerland
4
+ * (nFADP). ISO 3166-1 alpha-2.
5
+ */
6
+ export const defaultGdprCountries = [
7
+ 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR',
8
+ 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL',
9
+ 'PT', 'RO', 'SE', 'SI', 'SK',
10
+ 'IS', 'LI', 'NO',
11
+ 'GB',
12
+ 'CH',
13
+ ];
14
+ // `Set.has()` is O(1) vs `Array.includes()`'s O(n) — this runs on every
15
+ // request that has country-based gating enabled, so the lookup cost matters.
16
+ // Mirrors this package's `localesSet` convention in `config/middleware.ts`.
17
+ const defaultGdprCountriesSet = new Set(defaultGdprCountries);
18
+ // Custom `gdprCountries` lists are typically static config passed at
19
+ // `setIntlConfig` call time (module-scope, stable reference) — caching one
20
+ // Set per distinct array reference avoids rebuilding it on every request
21
+ // while still supporting a caller that legitimately swaps the array.
22
+ const customGdprCountriesSetCache = new WeakMap();
23
+ function getGdprCountriesSet(gdprCountries) {
24
+ if (!gdprCountries)
25
+ return defaultGdprCountriesSet;
26
+ let set = customGdprCountriesSetCache.get(gdprCountries);
27
+ if (!set) {
28
+ set = new Set(gdprCountries);
29
+ customGdprCountriesSetCache.set(gdprCountries, set);
30
+ }
31
+ return set;
32
+ }
33
+ /**
34
+ * Resolves whether the cookie-consent banner is required for a visitor.
35
+ *
36
+ * - Neither getter set: country-based gating is off entirely — consent is
37
+ * never required (the simplest opt-in-by-default setup).
38
+ * - Either getter set: fail-safe — a country that couldn't be resolved
39
+ * still requires consent; only a resolved country OUTSIDE
40
+ * `gdprCountries` skips the banner. `getCountryCode` takes precedence
41
+ * over `getCloudflareContext` when both are set.
42
+ */
43
+ export default async function resolveRequiresConsent(getCountryCode, getCloudflareContext, gdprCountries) {
44
+ if (!getCountryCode && !getCloudflareContext)
45
+ return false;
46
+ const countryCode = getCountryCode
47
+ ? await getCountryCode()
48
+ : (await getCloudflareContext({ async: true }))?.cf?.country;
49
+ if (!countryCode)
50
+ return true;
51
+ return getGdprCountriesSet(gdprCountries).has(countryCode);
52
+ }
@@ -3,5 +3,6 @@ export { default as useCookieConsent } from './client/use_cookie_consent';
3
3
  export { default as CookieConsentDialog } from './client/components/cookie_consent_dialog';
4
4
  export { default as PrivacyPolicyUpdateDialog } from './client/components/privacy_policy_update_dialog';
5
5
  export { default as CookieConsentAnalytics } from './client/components/cookie_consent_analytics';
6
+ export { defaultGdprCountries } from './gdpr_countries';
6
7
  export type { CookieConsentContextType, ConsentValue, CookieDialogClassNames, CookieDialogStyles } from './types';
7
- export type { CookieConsentRoutingConfig, CookieConsentAnalyticsSecrets } from '../types/types';
8
+ export type { CookieConsentRoutingConfig, CookieConsentAnalyticsSecrets, CookieConsentCloudflareContext, CookieConsentGetCloudflareContext, } from '../types/types';
@@ -3,3 +3,4 @@ export { default as useCookieConsent } from './client/use_cookie_consent';
3
3
  export { default as CookieConsentDialog } from './client/components/cookie_consent_dialog';
4
4
  export { default as PrivacyPolicyUpdateDialog } from './client/components/privacy_policy_update_dialog';
5
5
  export { default as CookieConsentAnalytics } from './client/components/cookie_consent_analytics';
6
+ export { defaultGdprCountries } from './gdpr_countries';
@@ -4,6 +4,7 @@ import { getMessage } from "../functions/server";
4
4
  import dynamic from "next/dynamic";
5
5
  import { localesSet } from "../../config/middleware";
6
6
  import config from "../../config/intl_config";
7
+ import resolveRequiresConsent from "../../cookie_consent/gdpr_countries";
7
8
  const LocationzationClientProvider = dynamic(() => import("../../client/components/client_provider"));
8
9
  let authUserServerProviderModule;
9
10
  /**
@@ -56,10 +57,16 @@ export default async function LocationzationProvider({ language, messages, child
56
57
  initialAuthUser = await authUserServerProviderModule.resolveAuthUserAndRedirect();
57
58
  }
58
59
  let analyticsSecrets;
59
- if (config.cookieConsent && config.cookieConsent.autoWireAnalytics !== false) {
60
- analyticsSecrets = config.cookieConsent.getSecrets
61
- ? await config.cookieConsent.getSecrets()
62
- : config.cookieConsent.secrets;
60
+ let requiresConsent = true;
61
+ if (config.cookieConsent) {
62
+ requiresConsent = await resolveRequiresConsent(config.cookieConsent.getCountryCode, config.cookieConsent.getCloudflareContext, config.cookieConsent.gdprCountries);
63
+ const isDevEnvironment = process.env.NODE_ENV === 'development';
64
+ const analyticsAllowedInEnv = config.cookieConsent.enableAnalyticsInDevMode === true || !isDevEnvironment;
65
+ if (config.cookieConsent.autoWireAnalytics !== false && analyticsAllowedInEnv) {
66
+ analyticsSecrets = config.cookieConsent.getSecrets
67
+ ? await config.cookieConsent.getSecrets()
68
+ : config.cookieConsent.secrets;
69
+ }
63
70
  }
64
- return _jsx(LocationzationClientProvider, { language: language, messages: messagesValue, initialAuthUser: initialAuthUser, skipAuthProvider: !autoWireClientProvider, analyticsSecrets: analyticsSecrets, children: children });
71
+ return _jsx(LocationzationClientProvider, { language: language, messages: messagesValue, initialAuthUser: initialAuthUser, skipAuthProvider: !autoWireClientProvider, analyticsSecrets: analyticsSecrets, requiresConsent: requiresConsent, children: children });
65
72
  }
@@ -1 +1 @@
1
- export type { CookieAttributes, LocalePrefixMode, Locales, ReturnType, RoutingConfig, TranslationEntry, TranslationObject, TranslatorReturnType, Alternates, changeFrequency, IntlSitemap, CookieConsentRoutingConfig, CookieConsentAnalyticsSecrets, } from './types';
1
+ export type { CookieAttributes, LocalePrefixMode, Locales, ReturnType, RoutingConfig, TranslationEntry, TranslationObject, TranslatorReturnType, Alternates, changeFrequency, IntlSitemap, CookieConsentRoutingConfig, CookieConsentAnalyticsSecrets, CookieConsentCloudflareContext, CookieConsentGetCloudflareContext, } from './types';
@@ -128,6 +128,78 @@ export interface CookieConsentRoutingConfig {
128
128
  * `undefined` in the returned object disables that provider's script.
129
129
  */
130
130
  getSecrets?: () => CookieConsentAnalyticsSecrets | Promise<CookieConsentAnalyticsSecrets>;
131
+ /**
132
+ * Resolves the visitor's country code directly (ISO 3166-1 alpha-2,
133
+ * e.g. `"DE"`) — the simplest option when you already have it from
134
+ * somewhere (a header, a KV lookup, your own logic). Takes precedence
135
+ * over `getCloudflareContext` when both are set.
136
+ */
137
+ getCountryCode?: () => string | undefined | Promise<string | undefined>;
138
+ /**
139
+ * Pass `getCloudflareContext` from `@opennextjs/cloudflare` directly
140
+ * (not a dependency of this package, so bring your own import) — its
141
+ * exact overloaded signature is accepted as-is; called internally with
142
+ * `{ async: true }`, so you never need to wrap it yourself. Only
143
+ * `cf.country` is read from the resolved context. Ignored when
144
+ * `getCountryCode` is also set.
145
+ *
146
+ * Country-based gating (via either `getCountryCode` or
147
+ * `getCloudflareContext`) decides whether the cookie-consent banner is
148
+ * required at all: visitors outside `gdprCountries` skip the banner and
149
+ * get analytics immediately (still gated by `enableAnalyticsInDevMode`).
150
+ * Omit BOTH to skip country-based gating entirely — the banner is never
151
+ * shown and consent is treated as implicitly granted for everyone. This
152
+ * is the simplest opt-in-by-default setup; set one of the two getters
153
+ * once you need real GDPR-region gating.
154
+ */
155
+ getCloudflareContext?: CookieConsentGetCloudflareContext;
156
+ /**
157
+ * Country codes (ISO 3166-1 alpha-2) for which the cookie-consent banner
158
+ * is required. Only consulted when `getCountryCode` or
159
+ * `getCloudflareContext` is set. Defaults to the EU/EEA + UK +
160
+ * Switzerland (GDPR/UK-GDPR/nFADP scope). A visitor whose resolved
161
+ * country isn't in this set is treated as NOT requiring consent; a
162
+ * country that couldn't be resolved still requires it (fail-safe:
163
+ * unknown defaults to "ask").
164
+ */
165
+ gdprCountries?: readonly string[];
166
+ /**
167
+ * Whether the auto-wired analytics scripts (see `autoWireAnalytics`)
168
+ * are allowed to load in your local/dev environment. Defaults to
169
+ * `false` — analytics stay off during local development regardless of
170
+ * consent, matching most analytics providers' own recommendation not to
171
+ * pollute production data with dev traffic. Set `true` to test the
172
+ * scripts locally.
173
+ */
174
+ enableAnalyticsInDevMode?: boolean;
175
+ }
176
+ /**
177
+ * Minimal shape read from your `getCloudflareContext()` return value — only
178
+ * `cf.country` is consulted, so any superset (the real `CloudflareContext`
179
+ * from `@opennextjs/cloudflare`) is accepted as-is without a hard
180
+ * dependency on that package.
181
+ */
182
+ export interface CookieConsentCloudflareContext {
183
+ cf?: {
184
+ country?: string;
185
+ };
186
+ }
187
+ /**
188
+ * Matches `@opennextjs/cloudflare`'s `getCloudflareContext` overloaded
189
+ * signature exactly, so that function can be passed as
190
+ * `cookieConsent.getCloudflareContext` directly — this package always
191
+ * calls it with `{ async: true }` internally (the first overload), which is
192
+ * why that overload's return type drives `resolveRequiresConsent`'s
193
+ * awaited result; the sync overload is accepted structurally only so the
194
+ * real function's type (which has both) is assignable as-is.
195
+ */
196
+ export interface CookieConsentGetCloudflareContext {
197
+ (options: {
198
+ async: true;
199
+ }): Promise<CookieConsentCloudflareContext | null>;
200
+ (options?: {
201
+ async: false;
202
+ }): CookieConsentCloudflareContext | null;
131
203
  }
132
204
  export interface CookieConsentAnalyticsSecrets {
133
205
  /** Cloudflare Web Analytics beacon token, e.g. `'{"token": "..."}'` (the raw `data-cf-beacon` attribute value). */
package/llms.txt CHANGED
@@ -41,7 +41,8 @@ other subpath can be used.
41
41
  - `./useCookieConsent` — context hook; throws `"useCookieConsent must be used within a CookieConsentProvider"` if called outside one.
42
42
  - `./CookieConsentDialog` — default cookie-consent banner; accepts per-slot `classNames`/`styles` or a `render` prop for fully custom markup.
43
43
  - `./PrivacyPolicyUpdateDialog` — "privacy policy updated" banner; auto-enabled only when `cookieConsent.privacyPolicyDate` is set.
44
- - `./cookieConsentAnalytics` — `CookieConsentAnalytics`: gates Cloudflare Web Analytics / Google Ads / Google Analytics / AdSense / Microsoft Clarity behind consent; rendered automatically by `IntlProvider` when `cookieConsent.secrets` or `getSecrets` is set (and `autoWireAnalytics !== false`).
44
+ - `./cookieConsentAnalytics` — `CookieConsentAnalytics`: gates Cloudflare Web Analytics / Google Ads / Google Analytics / AdSense / Microsoft Clarity behind consent; rendered automatically by `IntlProvider` when `cookieConsent.secrets` or `getSecrets` is set (and `autoWireAnalytics !== false`). Never renders in local dev (`NODE_ENV === 'development'`) unless `cookieConsent.enableAnalyticsInDevMode` is `true`.
45
+ - Country-based gating (`cookieConsent.getCountryCode` / `getCloudflareContext` + `gdprCountries`): resolved server-side by `IntlProvider` into a `requiresConsent` boolean passed to `CookieConsentProvider`. Neither getter set → gating off, consent always implicitly granted. `getCountryCode` (direct country resolver) takes precedence over `getCloudflareContext` (reads `cf.country`) when both are set. `getCloudflareContext` accepts `@opennextjs/cloudflare`'s `getCloudflareContext` function directly (its exact overloaded signature — `CookieConsentGetCloudflareContext`), called internally with `{ async: true }`. Unresolved country (or a `null` context) always requires consent (fail-safe). `./cookieConsent` also exports `defaultGdprCountries` (EU/EEA + UK + Switzerland).
45
46
 
46
47
  ## Conventions
47
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Optimized Next Intl Package Special for App Router and Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",