cloudflare-next-intl 0.4.0 → 0.4.1

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
@@ -198,6 +198,11 @@ export default setIntlConfig({
198
198
  defaultLocale: "en",
199
199
  cookieConsent: {
200
200
  privacyPolicyDate: "2026-01-01",
201
+ // Optional: gate the banner to GDPR-region visitors only. Omit both
202
+ // getters to disable country-based gating (consent always implicit).
203
+ getCloudflareContext: () => getCloudflareContext(),
204
+ // gdprCountries: [...], // defaults to EU/EEA + UK + Switzerland
205
+ // enableAnalyticsInDevMode: true, // analytics stay off in dev otherwise
201
206
  },
202
207
  });
203
208
  ```
@@ -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,33 @@
1
+ import { bench, describe } from 'vitest';
2
+ import resolveRequiresConsent, { defaultGdprCountries } from './gdpr_countries';
3
+ const customList = ['US', 'CA', 'MX'];
4
+ describe('resolveRequiresConsent: default GDPR list lookup', () => {
5
+ bench('country inside the list (worst case for Array.includes: near the end)', async () => {
6
+ await resolveRequiresConsent(() => 'CH', undefined, undefined);
7
+ });
8
+ bench('country outside the list', async () => {
9
+ await resolveRequiresConsent(() => 'US', undefined, undefined);
10
+ });
11
+ });
12
+ describe('resolveRequiresConsent: custom GDPR list lookup (cached Set)', () => {
13
+ bench('repeated calls with the same list reference', async () => {
14
+ await resolveRequiresConsent(() => 'US', undefined, customList);
15
+ });
16
+ });
17
+ describe('resolveRequiresConsent: gating disabled', () => {
18
+ bench('neither getter set (fast path, no lookup at all)', async () => {
19
+ await resolveRequiresConsent(undefined, undefined, undefined);
20
+ });
21
+ });
22
+ describe('resolveRequiresConsent: getCloudflareContext path', () => {
23
+ bench('resolves cf.country from an async context getter', async () => {
24
+ await resolveRequiresConsent(undefined, async () => ({ cf: { country: 'DE' } }), undefined);
25
+ });
26
+ });
27
+ // Sanity check the list itself isn't accidentally growing unbounded — a
28
+ // regression here would also regress the Set-build cost on first use.
29
+ describe('defaultGdprCountries', () => {
30
+ bench('Set construction cost (paid once per process, not per request)', () => {
31
+ new Set(defaultGdprCountries);
32
+ });
33
+ });
@@ -0,0 +1,18 @@
1
+ import type { CookieConsentCloudflareContext } 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: (() => CookieConsentCloudflareContext | Promise<CookieConsentCloudflareContext>) | 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()).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
8
  export type { CookieConsentRoutingConfig, CookieConsentAnalyticsSecrets } 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, } from './types';
@@ -128,6 +128,60 @@ 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
+ * Returns the Cloudflare request context — e.g. your own
140
+ * `getCloudflareContext()` call from `@opennextjs/cloudflare` (not a
141
+ * dependency of this package, so bring your own). Only `cf.country`
142
+ * is read from the resolved context. Ignored when `getCountryCode` is
143
+ * also set.
144
+ *
145
+ * Country-based gating (via either `getCountryCode` or
146
+ * `getCloudflareContext`) decides whether the cookie-consent banner is
147
+ * required at all: visitors outside `gdprCountries` skip the banner and
148
+ * get analytics immediately (still gated by `enableAnalyticsInDevMode`).
149
+ * Omit BOTH to skip country-based gating entirely — the banner is never
150
+ * shown and consent is treated as implicitly granted for everyone. This
151
+ * is the simplest opt-in-by-default setup; set one of the two getters
152
+ * once you need real GDPR-region gating.
153
+ */
154
+ getCloudflareContext?: () => CookieConsentCloudflareContext | Promise<CookieConsentCloudflareContext>;
155
+ /**
156
+ * Country codes (ISO 3166-1 alpha-2) for which the cookie-consent banner
157
+ * is required. Only consulted when `getCountryCode` or
158
+ * `getCloudflareContext` is set. Defaults to the EU/EEA + UK +
159
+ * Switzerland (GDPR/UK-GDPR/nFADP scope). A visitor whose resolved
160
+ * country isn't in this set is treated as NOT requiring consent; a
161
+ * country that couldn't be resolved still requires it (fail-safe:
162
+ * unknown defaults to "ask").
163
+ */
164
+ gdprCountries?: readonly string[];
165
+ /**
166
+ * Whether the auto-wired analytics scripts (see `autoWireAnalytics`)
167
+ * are allowed to load in your local/dev environment. Defaults to
168
+ * `false` — analytics stay off during local development regardless of
169
+ * consent, matching most analytics providers' own recommendation not to
170
+ * pollute production data with dev traffic. Set `true` to test the
171
+ * scripts locally.
172
+ */
173
+ enableAnalyticsInDevMode?: boolean;
174
+ }
175
+ /**
176
+ * Minimal shape read from your `getCloudflareContext()` return value — only
177
+ * `cf.country` is consulted, so any superset (the real `CloudflareContext`
178
+ * from `@opennextjs/cloudflare`) is accepted as-is without a hard
179
+ * dependency on that package.
180
+ */
181
+ export interface CookieConsentCloudflareContext {
182
+ cf?: {
183
+ country?: string;
184
+ };
131
185
  }
132
186
  export interface CookieConsentAnalyticsSecrets {
133
187
  /** 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. Unresolved country 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.1",
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",