cloudflare-next-intl 0.10.4 → 0.10.6

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.
@@ -12,6 +12,7 @@ export interface DynamicApiCheck {
12
12
  name: string;
13
13
  pattern: RegExp;
14
14
  }
15
+ export declare function hasSetLocaleCall(sourceText: string): boolean;
15
16
  export declare const USE_CLIENT_DIRECTIVE: RegExp;
16
17
  export declare function detectDynamicUsage(sourceText: string, extraChecks?: readonly DynamicApiCheck[]): DynamicDetectionResult;
17
18
  export declare function readExplicitDynamicValue(sourceText: string): 'force-static' | 'force-dynamic' | 'auto' | 'error' | null;
@@ -32,7 +32,6 @@ export function stripComments(sourceText) {
32
32
  const DYNAMIC_API_CHECKS = [
33
33
  { name: 'cookies()', pattern: /\bcookies\s*\(/ },
34
34
  { name: 'headers()', pattern: /\bheaders\s*\(\s*\)/ },
35
- { name: 'searchParams', pattern: /\bsearchParams\b/ },
36
35
  { name: 'unstable_noStore()', pattern: /\bunstable_noStore\s*\(/ },
37
36
  { name: 'connection()', pattern: /\bconnection\s*\(\s*\)/ },
38
37
  { name: 'cache: "no-store"', pattern: /cache:\s*['"]no-store['"]/ },
@@ -41,8 +40,12 @@ const DYNAMIC_API_CHECKS = [
41
40
  { name: 'withUserDb()', pattern: /\bwithUserDb\s*\(/ },
42
41
  ];
43
42
  const USE_AUTH_USER_CALL = /\buseAuthUser\s*\(/;
43
+ const SEARCH_PARAMS_IDENTIFIER = /\bsearchParams\b/;
44
44
  const TRANSLATIONS_CALL_NO_LOCALE = /\b(?:getTranslations|useTranslations)\s*\(\s*(?:['"][^'"]*['"]|[A-Za-z_$][\w$]*)\s*\)/;
45
45
  const SET_LOCALE_CALL = /\bsetLocale(?:Async)?\s*\(/;
46
+ export function hasSetLocaleCall(sourceText) {
47
+ return SET_LOCALE_CALL.test(stripComments(sourceText));
48
+ }
46
49
  export const USE_CLIENT_DIRECTIVE = /^(?:\s*['"]use \w[\w-]*['"]\s*;?\s*)*['"]use client['"]\s*;?/;
47
50
  const EXPLICIT_DYNAMIC_EXPORT = /export\s+const\s+dynamic\s*=/;
48
51
  export function detectDynamicUsage(sourceText, extraChecks = []) {
@@ -59,7 +62,12 @@ export function detectDynamicUsage(sourceText, extraChecks = []) {
59
62
  if (found !== null)
60
63
  matches.push({ name: 'useAuthUser()', line: lineOf(sourceText, found.index) });
61
64
  }
62
- if (!SET_LOCALE_CALL.test(code)) {
65
+ if (!USE_CLIENT_DIRECTIVE.test(sourceText)) {
66
+ const found = SEARCH_PARAMS_IDENTIFIER.exec(code);
67
+ if (found !== null)
68
+ matches.push({ name: 'searchParams', line: lineOf(sourceText, found.index) });
69
+ }
70
+ if (!USE_CLIENT_DIRECTIVE.test(sourceText) && !SET_LOCALE_CALL.test(code)) {
63
71
  const found = TRANSLATIONS_CALL_NO_LOCALE.exec(code);
64
72
  if (found !== null)
65
73
  matches.push({ name: 'getTranslations()/useTranslations() (cookie-derived locale)', line: lineOf(sourceText, found.index) });
@@ -20,8 +20,8 @@ function parseCallArgs(sourceText, start) {
20
20
  continue;
21
21
  }
22
22
  if (ch === '/' && sourceText[i + 1] === '/') {
23
- const nextNewline = sourceText.indexOf('\n', i);
24
- i = nextNewline === -1 ? sourceText.length : nextNewline;
23
+ const nextNewline = sourceText.indexOf('\n', i + 2);
24
+ i = nextNewline === -1 ? sourceText.length : nextNewline + 1;
25
25
  continue;
26
26
  }
27
27
  if (ch === '/' && sourceText[i + 1] === '*') {
@@ -1,7 +1,8 @@
1
- import { detectDynamicUsage } from './detect_dynamic_usage.js';
1
+ import { detectDynamicUsage, hasSetLocaleCall } from './detect_dynamic_usage.js';
2
2
  import { collectReachableFiles } from './collect_reachable_files.js';
3
3
  export function traceDynamicUsage(entryFile, entrySource, aliases, io, extraChecks = []) {
4
4
  const files = collectReachableFiles(entryFile, entrySource, aliases, io);
5
+ const entryHasSetLocale = hasSetLocaleCall(entrySource);
5
6
  let hasExplicitDynamicExport = false;
6
7
  const detectedApis = new Set();
7
8
  const signals = [];
@@ -13,6 +14,9 @@ export function traceDynamicUsage(entryFile, entrySource, aliases, io, extraChec
13
14
  first = false;
14
15
  }
15
16
  detection.matches.forEach(({ name, line }) => {
17
+ if (entryHasSetLocale && file !== entryFile && name === 'getTranslations()/useTranslations() (cookie-derived locale)') {
18
+ return;
19
+ }
16
20
  detectedApis.add(name);
17
21
  signals.push({ api: name, file, line });
18
22
  });
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve as resolvePath } from "node:path";
2
3
  const REQUIRED_AUTH_FIELDS = [
3
4
  "apiKey",
4
5
  "authDomain",
@@ -9,16 +10,7 @@ const REQUIRED_AUTH_FIELDS = [
9
10
  ];
10
11
  const REQUIRED_APP_CHECK_FIELDS = ["clientEmail", "appId"];
11
12
  const OAUTH_TRIPLE = ["oauthClientId", "oauthClientSecret", "oauthRefreshToken"];
12
- export function extractObjectLiteral(source, key) {
13
- const keyMatch = new RegExp(`(^|[\\s{,])${key}\\s*:`).exec(source);
14
- if (!keyMatch)
15
- return null;
16
- const afterKey = keyMatch.index + keyMatch[0].length;
17
- const open = source.indexOf("{", afterKey);
18
- if (open === -1)
19
- return null;
20
- if (/[,;]/.test(source.slice(afterKey, open)))
21
- return null;
13
+ function matchBraceLiteral(source, open) {
22
14
  let depth = 0;
23
15
  let index = open;
24
16
  let quote = null;
@@ -66,8 +58,286 @@ export function extractObjectLiteral(source, key) {
66
58
  }
67
59
  return null;
68
60
  }
61
+ function maskCommentsAndStrings(text) {
62
+ let result = "";
63
+ let quote = null;
64
+ let comment = null;
65
+ for (let index = 0; index < text.length; index += 1) {
66
+ const char = text[index];
67
+ const next = text[index + 1];
68
+ if (comment === "line") {
69
+ result += char === "\n" ? "\n" : " ";
70
+ if (char === "\n")
71
+ comment = null;
72
+ }
73
+ else if (comment === "block") {
74
+ if (char === "*" && next === "/") {
75
+ result += " ";
76
+ comment = null;
77
+ index += 1;
78
+ }
79
+ else {
80
+ result += char === "\n" ? "\n" : " ";
81
+ }
82
+ }
83
+ else if (quote) {
84
+ if (char === "\\") {
85
+ result += " ";
86
+ index += 1;
87
+ }
88
+ else {
89
+ result += char === "\n" ? "\n" : " ";
90
+ if (char === quote)
91
+ quote = null;
92
+ }
93
+ }
94
+ else if (char === "/" && next === "/") {
95
+ comment = "line";
96
+ result += " ";
97
+ index += 1;
98
+ }
99
+ else if (char === "/" && next === "*") {
100
+ comment = "block";
101
+ result += " ";
102
+ index += 1;
103
+ }
104
+ else if (char === '"' || char === "'" || char === "`") {
105
+ quote = char;
106
+ result += " ";
107
+ }
108
+ else {
109
+ result += char;
110
+ }
111
+ }
112
+ return result;
113
+ }
114
+ export function extractObjectLiteral(source, key) {
115
+ const masked = maskCommentsAndStrings(source);
116
+ const keyMatch = new RegExp(`(^|[\\s{,])${key}\\s*:`).exec(masked);
117
+ if (!keyMatch)
118
+ return null;
119
+ const afterKey = keyMatch.index + keyMatch[0].length;
120
+ const open = masked.indexOf("{", afterKey);
121
+ if (open === -1)
122
+ return null;
123
+ if (/[,;]/.test(masked.slice(afterKey, open)))
124
+ return null;
125
+ return matchBraceLiteral(source, open);
126
+ }
127
+ function extractAssignedObjectLiteral(source, name) {
128
+ const masked = maskCommentsAndStrings(source);
129
+ const declMatch = new RegExp(`(?:^|[\\s;}])(?:export\\s+)?(?:const|let|var)\\s+${name}\\b[^=;]*=`).exec(masked);
130
+ if (!declMatch)
131
+ return null;
132
+ const afterEq = declMatch.index + declMatch[0].length;
133
+ const open = masked.indexOf("{", afterEq);
134
+ if (open === -1)
135
+ return null;
136
+ if (/[;\n]\S/.test(masked.slice(afterEq, open)))
137
+ return null;
138
+ return matchBraceLiteral(source, open);
139
+ }
140
+ function findTopLevelSpreadNames(body) {
141
+ const names = [];
142
+ let depth = 0;
143
+ let quote = null;
144
+ let comment = null;
145
+ for (let index = 0; index < body.length; index += 1) {
146
+ const char = body[index];
147
+ const next = body[index + 1];
148
+ if (comment === "line") {
149
+ if (char === "\n")
150
+ comment = null;
151
+ }
152
+ else if (comment === "block") {
153
+ if (char === "*" && next === "/") {
154
+ comment = null;
155
+ index += 1;
156
+ }
157
+ }
158
+ else if (quote) {
159
+ if (char === "\\")
160
+ index += 1;
161
+ else if (char === quote)
162
+ quote = null;
163
+ }
164
+ else if (char === "/" && next === "/") {
165
+ comment = "line";
166
+ index += 1;
167
+ }
168
+ else if (char === "/" && next === "*") {
169
+ comment = "block";
170
+ index += 1;
171
+ }
172
+ else if (char === '"' || char === "'" || char === "`") {
173
+ quote = char;
174
+ }
175
+ else if (char === "{" || char === "[" || char === "(") {
176
+ depth += 1;
177
+ }
178
+ else if (char === "}" || char === "]" || char === ")") {
179
+ depth -= 1;
180
+ }
181
+ else if (depth === 0 && char === "." && next === "." && body[index + 2] === ".") {
182
+ const nameMatch = /^([A-Za-z_$][\w$]*)/.exec(body.slice(index + 3));
183
+ if (nameMatch)
184
+ names.push(nameMatch[1]);
185
+ index += 2;
186
+ }
187
+ }
188
+ return names;
189
+ }
190
+ const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs"];
191
+ const tsconfigAliasCache = new Map();
192
+ function stripJsonComments(text) {
193
+ let stripped = "";
194
+ let inString = false;
195
+ for (let index = 0; index < text.length; index += 1) {
196
+ const char = text[index];
197
+ const next = text[index + 1];
198
+ if (inString) {
199
+ stripped += char;
200
+ if (char === "\\") {
201
+ stripped += next ?? "";
202
+ index += 1;
203
+ }
204
+ else if (char === '"') {
205
+ inString = false;
206
+ }
207
+ continue;
208
+ }
209
+ if (char === '"') {
210
+ inString = true;
211
+ stripped += char;
212
+ }
213
+ else if (char === "/" && next === "/") {
214
+ while (index < text.length && text[index] !== "\n")
215
+ index += 1;
216
+ stripped += "\n";
217
+ }
218
+ else if (char === "/" && next === "*") {
219
+ index += 2;
220
+ while (index < text.length && !(text[index] === "*" && text[index + 1] === "/"))
221
+ index += 1;
222
+ index += 1;
223
+ }
224
+ else {
225
+ stripped += char;
226
+ }
227
+ }
228
+ return stripped.replace(/,(\s*[}\]])/g, "$1");
229
+ }
230
+ function findTsconfigAliases(fromDir) {
231
+ if (tsconfigAliasCache.has(fromDir))
232
+ return tsconfigAliasCache.get(fromDir);
233
+ let dir = fromDir;
234
+ let result = null;
235
+ for (let depth = 0; depth < 12; depth += 1) {
236
+ const candidate = resolvePath(dir, "tsconfig.json");
237
+ if (existsSync(candidate)) {
238
+ try {
239
+ const parsed = JSON.parse(stripJsonComments(readFileSync(candidate, "utf8")));
240
+ const paths = parsed?.compilerOptions?.paths;
241
+ if (paths && typeof paths === "object") {
242
+ result = { baseDir: resolvePath(dir, parsed?.compilerOptions?.baseUrl ?? "."), paths };
243
+ }
244
+ }
245
+ catch {
246
+ result = null;
247
+ }
248
+ break;
249
+ }
250
+ const parent = dirname(dir);
251
+ if (parent === dir)
252
+ break;
253
+ dir = parent;
254
+ }
255
+ tsconfigAliasCache.set(fromDir, result);
256
+ return result;
257
+ }
258
+ function resolveAliasSpecifier(fromFile, specifier) {
259
+ const aliases = findTsconfigAliases(dirname(fromFile));
260
+ if (!aliases)
261
+ return null;
262
+ for (const [pattern, targets] of Object.entries(aliases.paths)) {
263
+ const target = targets[0];
264
+ if (!target)
265
+ continue;
266
+ if (pattern.endsWith("/*") && specifier.startsWith(pattern.slice(0, -1))) {
267
+ return resolvePath(aliases.baseDir, target.slice(0, -1) + specifier.slice(pattern.length - 1));
268
+ }
269
+ if (pattern === specifier) {
270
+ return resolvePath(aliases.baseDir, target);
271
+ }
272
+ }
273
+ return null;
274
+ }
275
+ function resolveModuleFile(fromFile, specifier) {
276
+ const target = specifier.startsWith(".")
277
+ ? resolvePath(dirname(fromFile), specifier)
278
+ : resolveAliasSpecifier(fromFile, specifier);
279
+ if (!target)
280
+ return null;
281
+ for (const candidate of [target, ...RESOLVE_EXTENSIONS.map((ext) => target + ext)]) {
282
+ if (existsSync(candidate))
283
+ return candidate;
284
+ }
285
+ return null;
286
+ }
287
+ function findNamedImportSpecifier(source, localName) {
288
+ const importRegex = /import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
289
+ let match;
290
+ while ((match = importRegex.exec(source)) !== null) {
291
+ for (const raw of match[1].split(",")) {
292
+ const spec = raw.trim();
293
+ if (!spec)
294
+ continue;
295
+ const asMatch = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(spec);
296
+ const imported = asMatch ? asMatch[1] : spec;
297
+ const local = asMatch ? asMatch[2] : spec;
298
+ if (local === localName)
299
+ return { imported, from: match[2] };
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+ function resolveSpreadBody(name, source, fromFile, cache) {
305
+ const cacheKey = `${fromFile ?? ""}::${name}`;
306
+ if (cache.has(cacheKey))
307
+ return cache.get(cacheKey);
308
+ let result = extractAssignedObjectLiteral(source, name)?.body ?? null;
309
+ if (result === null && fromFile) {
310
+ const namedImport = findNamedImportSpecifier(source, name);
311
+ if (namedImport) {
312
+ const file = resolveModuleFile(fromFile, namedImport.from);
313
+ if (file) {
314
+ try {
315
+ const fileSource = readFileSync(file, "utf8");
316
+ result = extractAssignedObjectLiteral(fileSource, namedImport.imported)?.body ?? null;
317
+ }
318
+ catch {
319
+ result = null;
320
+ }
321
+ }
322
+ }
323
+ }
324
+ cache.set(cacheKey, result);
325
+ return result;
326
+ }
327
+ function resolveSpreadBodies(mainBody, source, fromFile, cache) {
328
+ const bodies = [mainBody];
329
+ let hasUnresolvedSpread = false;
330
+ for (const name of findTopLevelSpreadNames(mainBody)) {
331
+ const resolved = resolveSpreadBody(name, source, fromFile, cache);
332
+ if (resolved !== null)
333
+ bodies.push(resolved);
334
+ else
335
+ hasUnresolvedSpread = true;
336
+ }
337
+ return { bodies, hasUnresolvedSpread };
338
+ }
69
339
  export function extractFieldValue(body, key) {
70
- const keyMatch = new RegExp(`(^|[\\s{,])${key}\\s*:`).exec(body);
340
+ const keyMatch = new RegExp(`(^|[\\s{,])${key}\\s*:`).exec(maskCommentsAndStrings(body));
71
341
  if (!keyMatch)
72
342
  return null;
73
343
  const start = keyMatch.index + keyMatch[0].length;
@@ -139,10 +409,20 @@ function evaluateValue(value, env) {
139
409
  }
140
410
  return { ok: true };
141
411
  }
142
- function checkField(body, path, key, severity, env, baseLine) {
412
+ function checkField(bodies, hasUnresolvedSpread, path, key, severity, env, baseLine) {
143
413
  const field = `${path}.${key}`;
144
- const found = extractFieldValue(body, key);
414
+ let found = null;
415
+ let fromMainBody = false;
416
+ for (let index = 0; index < bodies.length; index += 1) {
417
+ found = extractFieldValue(bodies[index], key);
418
+ if (found) {
419
+ fromMainBody = index === 0;
420
+ break;
421
+ }
422
+ }
145
423
  if (!found) {
424
+ if (hasUnresolvedSpread)
425
+ return null;
146
426
  return { field, severity, reason: "missing from the config" };
147
427
  }
148
428
  const verdict = evaluateValue(found.value, env);
@@ -153,7 +433,7 @@ function checkField(body, path, key, severity, env, baseLine) {
153
433
  severity,
154
434
  reason: verdict.reason,
155
435
  ...(verdict.envVar ? { envVar: verdict.envVar } : {}),
156
- lineNumber: baseLine + found.lineNumber - 1,
436
+ ...(fromMainBody ? { lineNumber: baseLine + found.lineNumber - 1 } : {}),
157
437
  };
158
438
  }
159
439
  export function formatFirebaseAuthConfigMessage(issues, intlConfigPath) {
@@ -209,23 +489,26 @@ export function checkFirebaseAuthConfig(options = {}) {
209
489
  }
210
490
  const baseLine = source.slice(0, firebaseAuth.start).split("\n").length;
211
491
  const issues = [];
492
+ const resolveCache = new Map();
493
+ const { bodies: authBodies, hasUnresolvedSpread: authHasUnresolvedSpread } = resolveSpreadBodies(firebaseAuth.body, source, options.intlConfigPath, resolveCache);
212
494
  for (const key of REQUIRED_AUTH_FIELDS) {
213
- const issue = checkField(firebaseAuth.body, "firebaseAuth", key, "error", env, baseLine);
495
+ const issue = checkField(authBodies, authHasUnresolvedSpread, "firebaseAuth", key, "error", env, baseLine);
214
496
  if (issue)
215
497
  issues.push(issue);
216
498
  }
217
499
  const appCheck = extractObjectLiteral(firebaseAuth.body, "appCheck");
218
500
  if (appCheck) {
219
501
  const appCheckLine = baseLine + firebaseAuth.body.slice(0, appCheck.start).split("\n").length - 1;
220
- const optedOut = /reportMissingServerCredentials\s*:\s*false/.test(appCheck.body);
502
+ const optedOut = /reportMissingServerCredentials\s*:\s*false/.test(maskCommentsAndStrings(appCheck.body));
503
+ const { bodies: appCheckBodies, hasUnresolvedSpread: appCheckHasUnresolvedSpread } = resolveSpreadBodies(appCheck.body, source, options.intlConfigPath, resolveCache);
221
504
  if (!optedOut) {
222
505
  for (const key of REQUIRED_APP_CHECK_FIELDS) {
223
- const issue = checkField(appCheck.body, "firebaseAuth.appCheck", key, "warning", env, appCheckLine);
506
+ const issue = checkField(appCheckBodies, appCheckHasUnresolvedSpread, "firebaseAuth.appCheck", key, "warning", env, appCheckLine);
224
507
  if (issue)
225
508
  issues.push(issue);
226
509
  }
227
- const privateKey = checkField(appCheck.body, "firebaseAuth.appCheck", "privateKey", "warning", env, appCheckLine);
228
- const triple = OAUTH_TRIPLE.map((key) => checkField(appCheck.body, "firebaseAuth.appCheck", key, "warning", env, appCheckLine));
510
+ const privateKey = checkField(appCheckBodies, appCheckHasUnresolvedSpread, "firebaseAuth.appCheck", "privateKey", "warning", env, appCheckLine);
511
+ const triple = OAUTH_TRIPLE.map((key) => checkField(appCheckBodies, appCheckHasUnresolvedSpread, "firebaseAuth.appCheck", key, "warning", env, appCheckLine));
229
512
  if (privateKey && triple.some((issue) => issue !== null)) {
230
513
  const partialTriple = triple.some((issue) => issue === null);
231
514
  if (partialTriple) {
@@ -27,7 +27,7 @@ export function firebaseAuthCheckPlugin(options = {}) {
27
27
  if (report.issues.length === 0)
28
28
  return;
29
29
  console.warn(report.formattedMessage);
30
- if (!report.valid && options.strict) {
30
+ if (!report.valid && options.strict !== false) {
31
31
  throw new Error("[cloudflare-next-intl] Build failed: `firebaseAuth` config is incomplete. See details above.");
32
32
  }
33
33
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
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",