cloudflare-next-intl 0.9.47 → 0.9.48

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.
Files changed (29) hide show
  1. package/README.md +15 -7
  2. package/bin/check_locale_params.mjs +26 -0
  3. package/dist/src/dynamic_pages_check/check_dynamic_pages.js +7 -2
  4. package/dist/src/dynamic_pages_check/detect_dynamic_usage.js +7 -0
  5. package/dist/src/dynamic_pages_check/find_page_files.js +1 -1
  6. package/dist/src/locale_params_check/check_locale_params.d.ts +24 -0
  7. package/dist/src/locale_params_check/check_locale_params.js +104 -0
  8. package/dist/src/locale_params_check/detect_locale_params.d.ts +10 -0
  9. package/dist/src/locale_params_check/detect_locale_params.js +96 -0
  10. package/dist/src/locale_params_check/find_locale_scoped_files.d.ts +1 -0
  11. package/dist/src/locale_params_check/find_locale_scoped_files.js +14 -0
  12. package/dist/src/locale_params_check/index.d.ts +4 -0
  13. package/dist/src/locale_params_check/index.js +4 -0
  14. package/dist/src/locale_params_check/insert_locale_params.d.ts +5 -0
  15. package/dist/src/locale_params_check/insert_locale_params.js +117 -0
  16. package/dist/src/vite/auto_dynamic_pages_plugin.js +2 -24
  17. package/dist/src/vite/auto_locale_params_plugin.d.ts +18 -0
  18. package/dist/src/vite/auto_locale_params_plugin.js +71 -0
  19. package/dist/src/vite/build_write_restore_stack.d.ts +1 -0
  20. package/dist/src/vite/build_write_restore_stack.js +43 -0
  21. package/dist/src/vite/index.d.ts +2 -0
  22. package/dist/src/vite/index.js +2 -0
  23. package/dist/src/vite/lucide_optimizer_plugin.d.ts +21 -0
  24. package/dist/src/vite/lucide_optimizer_plugin.js +190 -0
  25. package/dist/src/vite/plugin.d.ts +4 -0
  26. package/dist/src/vite/plugin.js +12 -0
  27. package/dist/src/vite/vinext_route_wiring_fix.js +71 -24
  28. package/llms.txt +3 -1
  29. package/package.json +7 -2
package/README.md CHANGED
@@ -261,12 +261,14 @@ export default defineConfig({
261
261
  The scan (`checkDynamicPages`, also usable standalone from `cloudflare-next-intl/checkDynamicPages`) is a text heuristic, not a real parser, so it's deliberately conservative and follows a page's own local (relative/`@/`-alias) imports transitively — cycle-safe, capped at 300 files — so a signal several files away (a component's repository calling `cookies()`) still marks the page dynamic, not just literal text in the page file itself. A locally-imported file that opens with a `"use server"` directive is never opened by the scan (its exports are Server Actions, invoked only on explicit call — never merely by being imported), the same treatment a bare npm-package import already gets. Recognized signals: `cookies()`, `headers()`, `searchParams`, `unstable_noStore()`, `connection()`, `cache: "no-store"`, `next: { revalidate: 0 }`, and this package's own `getAuthUser()`/`useAuthUser()`/`withUserDb()` (each of which reads `cookies()` internally) — except a `useAuthUser()` call in a file that opens with `"use client"`, which is this package's client-side hook (a different export under the same name) and contributes no signal. Set `resolveImports: false` on `checkDynamicPages` to restore the original single-file-only scan, or pass `aliases` to override the default `@/` → `<appDir>/..` mapping.
262
262
 
263
263
  Being text-only and transitive, it can over-flag: any local file the page reaches — however many imports away — that merely *calls* a recognized signal counts, even along a branch that never runs in production (a `Config.isDev`-gated `fetch(..., { cache: "no-store" })`) or one that's optional/best-effort (a `try`/`catch`-wrapped `getAuthUser()` used only to tag a log line). It cannot see that a call is conditional or swallowed. For a page whose only reason for being flagged is that kind of optional read — attaching "whoever's signed in, if anyone" to an error report, analytics event, or log line — switch that read to `resolveOptionalAuthUser()` (`cloudflare-next-intl/resolveOptionalAuthUser`, see Firebase Auth below): it wraps `getAuthUser()` the same way but, being an npm-package import, is a boundary the scan doesn't open, so it contributes no signal. If that read lives in a shared `onError` sink used by many pages at once, `resolveErrorReportingUser(useAuthUser?)` (same subpath) is the more precise fix: it's off (`{ user: null }`, no `getAuthUser()` call) by default, and only a `reportError({ ..., useAuthUser: true })` call site that actually wants the user on that report opts in per-call — instead of every page reaching that sink getting flagged.
264
- 2. **Build-Time Image Optimizer (`imageOptimizer`)**: Automatically scans your image directories (`public/images`, `public/icons`), downscales oversized assets, produces sibling formats (`webp` by default; also supports `avif`, `png`, `jpeg`, `gif`, `tiff`, `heif`, `jp2`, `jxl`), generates 8px `.blur.webp` thumbnails with Next.js-matching SVG Gaussian blur placeholders, and provides transparent `<Image placeholder="blur" />` shimming via virtual modules. When more than one format is generated for an image, the shim renders a `<picture>` with one `<source>` per format — ordered exactly as configured — so the browser picks the best format it supports, with the original untouched file as an `onError` fallback if a generated asset fails to load. When the same image is used at different widths across the codebase, each size gets its own generated variant, and each `<Image>` usage automatically resolves to the closest matching size. Runs on production build only by default (`dev: false`) downscaling/format conversion is a build concern, and re-scanning on every dev server start slows cold starts for no dev-time benefit; the shim degrades cleanly with no manifest (plain, unoptimized `next/image` rendering, no blur placeholder). Pass `dev: true` to also run it in dev and preview real optimized output/blur.
265
- 3. **Locale File Bundling & Resolution (`localeFiles`)**: Resolves `@locale-file/*` to your `./messages` directory and transforms dynamic imports into `import.meta.glob('/messages/*.json', { eager: true })` for lightning-fast locale loading on Cloudflare Workers.
266
- 4. **User-Agent Stub (`userAgentStub`)**: Prevents Next.js `user-agent` from importing `node:fs` during workerd runtime execution (which otherwise causes runtime 404 / 500 crashes in Workers proxy/middleware).
267
- 5. **Cloudflare Workers Client Stub (`cfWorkersClientStub`)**: Stubs `cloudflare:workers` in client builds so shared modules can be referenced without client bundling errors.
268
- 6. **Build ID Asset Emission (`buildIdAsset`)**: Emits `BUILD_ID` static asset in the client build directory from `process.env.__VINEXT_SHARED_BUILD_ID` or `process.env.__VINEXT_BUILD_ID`.
269
- 7. **Vinext Route Wiring & Optimistic Prefetch Fix (`vinextRouteWiringFix`)**: Patches Vinext runtime route wiring, route matching, optimistic route template resolution, and prefetch learning so pending prefetches with already cached templates don't block navigation, and leading `:locale` segments route correctly.
264
+ 2. **Auto Locale Params for Static Pages (`autoLocaleParams`)**: Automatically scans `page`/`layout`/`loading` files under a `[locale]`-scoped route during `configResolved` and inserts the `locale` param from `params` plus a `setLocale(locale)` call wherever `getTranslations()`/`useTranslations()` is used without one the common cause of a page reading `NEXT_LOCALE` from cookies (and so implicitly becoming request-dependent) even though it never calls `cookies()` itself. Runs before `autoDynamicPages` so a page fixed this way no longer trips the cookie-derived-locale signal below. The scan (`checkLocaleParams`, also usable standalone from `cloudflare-next-intl/checkLocaleParams`, or via the `cfni-check-locale-params` CLI bin) is a text heuristic like `checkDynamicPages`; pass `mode: "report"` to only log without editing files, or `skip`/`overrides`/`localeParam` to fine-tune per-file.
265
+ 4. **Build-Time Image Optimizer (`imageOptimizer`)**: Automatically scans your image directories (`public/images`, `public/icons`), downscales oversized assets, produces sibling formats (`webp` by default; also supports `avif`, `png`, `jpeg`, `gif`, `tiff`, `heif`, `jp2`, `jxl`), generates 8px `.blur.webp` thumbnails with Next.js-matching SVG Gaussian blur placeholders, and provides transparent `<Image placeholder="blur" />` shimming via virtual modules. When more than one format is generated for an image, the shim renders a `<picture>` with one `<source>` per format — ordered exactly as configured — so the browser picks the best format it supports, with the original untouched file as an `onError` fallback if a generated asset fails to load. When the same image is used at different widths across the codebase, each size gets its own generated variant, and each `<Image>` usage automatically resolves to the closest matching size. Runs on production build only by default (`dev: false`) — downscaling/format conversion is a build concern, and re-scanning on every dev server start slows cold starts for no dev-time benefit; the shim degrades cleanly with no manifest (plain, unoptimized `next/image` rendering, no blur placeholder). Pass `dev: true` to also run it in dev and preview real optimized output/blur.
266
+ 5. **Locale File Bundling & Resolution (`localeFiles`)**: Resolves `@locale-file/*` to your `./messages` directory and transforms dynamic imports into `import.meta.glob('/messages/*.json', { eager: true })` for lightning-fast locale loading on Cloudflare Workers.
267
+ 6. **User-Agent Stub (`userAgentStub`)**: Prevents Next.js `user-agent` from importing `node:fs` during workerd runtime execution (which otherwise causes runtime 404 / 500 crashes in Workers proxy/middleware).
268
+ 7. **Cloudflare Workers Client Stub (`cfWorkersClientStub`)**: Stubs `cloudflare:workers` in client builds so shared modules can be referenced without client bundling errors.
269
+ 8. **Build ID Asset Emission (`buildIdAsset`)**: Emits `BUILD_ID` static asset in the client build directory from `process.env.__VINEXT_SHARED_BUILD_ID` or `process.env.__VINEXT_BUILD_ID`.
270
+ 9. **Vinext Route Wiring & Optimistic Prefetch Fix (`vinextRouteWiringFix`)**: Patches Vinext runtime route wiring, route matching, optimistic route template resolution, and prefetch learning so pending prefetches with already cached templates don't block navigation, and leading `:locale` segments route correctly.
271
+ 10. **Lucide & Next.js Specifier Optimizer (`lucideOptimizer`)**: Auto-detects `lucide-react` in project dependencies, rewrites named imports to direct deep icon paths (`lucide-react/dist/esm/icons/<icon>.mjs`) to avoid browser socket exhaustion (`ERR_INSUFFICIENT_RESOURCES`), and normalizes Next.js `.js` specifiers (`next/dynamic.js` -> `next/dynamic`) to prevent mid-session Vite re-optimization and React dispatcher splitting.
270
272
 
271
273
  ##### Plugin Options
272
274
  All features are enabled by default, and can be individually configured or toggled off:
@@ -292,6 +294,11 @@ export default defineConfig({
292
294
  "/images/logo.png": { formats: false, blur: false },
293
295
  },
294
296
  },
297
+ autoLocaleParams: { // Auto-insert locale params/setLocale (or `false` to disable)
298
+ mode: "fix", // "fix" | "report" | "off" (default: "fix")
299
+ localeParam: "locale", // Route param name to read (default: "locale")
300
+ skip: ["src/app/[locale]/(marketing)/**"], // Glob(s) to exclude from the scan
301
+ },
295
302
  messagesDir: "./messages", // Path to locale JSON files (default: './messages')
296
303
  intlConfigPath: "./src/l18n/intl_config.ts", // Path to intl config (auto-detected if omitted)
297
304
  buildIdAsset: true, // Emit BUILD_ID asset (or custom string filename, default: true)
@@ -299,13 +306,14 @@ export default defineConfig({
299
306
  userAgentStub: true, // Enable regex-based user-agent stub (default: true)
300
307
  cfWorkersClientStub: true, // Enable client cloudflare:workers stub (default: true)
301
308
  vinextRouteWiringFix: true, // Enable vinext route wiring, matching, and prefetch fixes (default: true, or options object)
309
+ lucideOptimizer: true, // Auto-optimize lucide-react deep imports and normalize next/*.js (default: true, or options object)
302
310
  }),
303
311
  ],
304
312
  });
305
313
  ```
306
314
 
307
315
  Individual standalone plugins are also exported if you only need a specific feature:
308
- `imageOptimizerPlugin` (or `imageOptimizer`), `buildIdAsset`, `localeFilePlugin`, `userAgentStubPlugin`, `cfWorkersClientStubPlugin`, `vinextRouteWiringFixPlugin`.
316
+ `imageOptimizerPlugin` (or `imageOptimizer`), `autoLocaleParamsPlugin`, `buildIdAsset`, `localeFilePlugin`, `userAgentStubPlugin`, `cfWorkersClientStubPlugin`, `vinextRouteWiringFixPlugin`, `lucideOptimizerPlugin`.
309
317
 
310
318
  ##### Per-Image Optimizer Settings
311
319
 
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // Usage: cfni-check-locale-params [--app-dir=src/app] [--mode=off|report|fix] [--locale-param=locale] [--skip=a/page.tsx,b/page.tsx] [--verbose]
3
+ // Env equivalents: CFNI_LOCALE_PARAMS_APP_DIR, CFNI_LOCALE_PARAMS_MODE, CFNI_LOCALE_PARAMS_LOCALE_PARAM, CFNI_LOCALE_PARAMS_SKIP (comma-separated), CFNI_LOCALE_PARAMS_VERBOSE.
4
+ import { resolve } from 'node:path';
5
+ import { checkLocaleParams } from '../dist/src/locale_params_check/check_locale_params.js';
6
+
7
+ function argValue(name) {
8
+ const prefix = `--${name}=`;
9
+ const arg = process.argv.slice(2).find((a) => a.startsWith(prefix));
10
+ return arg ? arg.slice(prefix.length) : undefined;
11
+ }
12
+
13
+ const appDir = resolve(argValue('app-dir') ?? process.env.CFNI_LOCALE_PARAMS_APP_DIR ?? 'src/app');
14
+ const mode = argValue('mode') ?? process.env.CFNI_LOCALE_PARAMS_MODE ?? 'report';
15
+ const localeParam = argValue('locale-param') ?? process.env.CFNI_LOCALE_PARAMS_LOCALE_PARAM ?? 'locale';
16
+ const skipRaw = argValue('skip') ?? process.env.CFNI_LOCALE_PARAMS_SKIP ?? '';
17
+ const skip = skipRaw.split(',').map((s) => s.trim()).filter(Boolean).map((s) => resolve(s));
18
+ const verbose = process.argv.includes('--verbose') || process.env.CFNI_LOCALE_PARAMS_VERBOSE === 'true';
19
+
20
+ const reports = await checkLocaleParams({ appDir, mode, localeParam, skip, verbose });
21
+
22
+ if (reports.length === 0) {
23
+ console.log(mode === 'off' ? 'checkLocaleParams: disabled (mode=off).' : `checkLocaleParams: no [${localeParam}]-scoped page/layout/loading files found under ${appDir}.`);
24
+ } else if (!verbose) {
25
+ for (const { file, action } of reports) console.log(`${action.padEnd(24)} ${file}`);
26
+ }
@@ -51,6 +51,10 @@ function displayPath(file) {
51
51
  const rel = relative(process.cwd(), file);
52
52
  return rel === '' || rel.startsWith('..') ? file : rel;
53
53
  }
54
+ function fileKind(file) {
55
+ const match = /([a-z]+)\.(?:tsx|ts|jsx|js)$/.exec(file);
56
+ return match ? match[1] : file;
57
+ }
54
58
  function logReports(reports, appDir, pageLabel) {
55
59
  console.log(`[cloudflare-next-intl] dynamic-pages check\n${LEGEND}\n`);
56
60
  reports.forEach((report, index) => {
@@ -58,8 +62,9 @@ function logReports(reports, appDir, pageLabel) {
58
62
  const branch = isLast ? '└' : '├';
59
63
  const isApi = isApiRoute(report.file);
60
64
  const glyph = actionGlyph(report, isApi);
61
- const route = deriveRoute(appDir, report.file);
62
- console.log(`${branch} ${glyph} ${route} ${pageLabel(report.file)} — ${actionDetail(report, isApi)}`);
65
+ const kind = fileKind(report.file);
66
+ const route = deriveRoute(appDir, report.file) + (kind === 'page' || kind === 'route' ? '' : `/${kind}`);
67
+ console.log(`${branch} ${glyph} ${route} ${pageLabel(report.file)} [${kind}] — ${actionDetail(report, isApi)}`);
63
68
  const continuation = isLast ? ' ' : '│';
64
69
  for (const signal of report.signals ?? []) {
65
70
  const location = `${displayPath(signal.file)}:${signal.line}`;
@@ -41,6 +41,8 @@ const DYNAMIC_API_CHECKS = [
41
41
  { name: 'withUserDb()', pattern: /\bwithUserDb\s*\(/ },
42
42
  ];
43
43
  const USE_AUTH_USER_CALL = /\buseAuthUser\s*\(/;
44
+ const TRANSLATIONS_CALL_NO_LOCALE = /\b(?:getTranslations|useTranslations)\s*\(\s*(?:['"][^'"]*['"]|[A-Za-z_$][\w$]*)\s*\)/;
45
+ const SET_LOCALE_CALL = /\bsetLocale(?:Async)?\s*\(/;
44
46
  export const USE_CLIENT_DIRECTIVE = /^(?:\s*['"]use \w[\w-]*['"]\s*;?\s*)*['"]use client['"]\s*;?/;
45
47
  const EXPLICIT_DYNAMIC_EXPORT = /export\s+const\s+dynamic\s*=/;
46
48
  export function detectDynamicUsage(sourceText, extraChecks = []) {
@@ -57,6 +59,11 @@ export function detectDynamicUsage(sourceText, extraChecks = []) {
57
59
  if (found !== null)
58
60
  matches.push({ name: 'useAuthUser()', line: lineOf(sourceText, found.index) });
59
61
  }
62
+ if (!SET_LOCALE_CALL.test(code)) {
63
+ const found = TRANSLATIONS_CALL_NO_LOCALE.exec(code);
64
+ if (found !== null)
65
+ matches.push({ name: 'getTranslations()/useTranslations() (cookie-derived locale)', line: lineOf(sourceText, found.index) });
66
+ }
60
67
  return {
61
68
  hasExplicitDynamicExport: EXPLICIT_DYNAMIC_EXPORT.test(code),
62
69
  detectedDynamicApis: matches.map((m) => m.name),
@@ -1,6 +1,6 @@
1
1
  import { readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- const PAGE_FILE_NAMES = new Set(['page.tsx', 'page.ts', 'page.jsx', 'page.js', 'route.ts', 'route.js']);
3
+ const PAGE_FILE_NAMES = new Set(['page.tsx', 'page.ts', 'page.jsx', 'page.js', 'route.ts', 'route.js', 'loading.tsx', 'loading.ts', 'loading.jsx', 'loading.js']);
4
4
  export function findPageFiles(appDir) {
5
5
  let entries;
6
6
  try {
@@ -0,0 +1,24 @@
1
+ import { type PageLabelStyle } from '../dynamic_pages_check/derive_page_label.js';
2
+ export type LocaleParamsCheckMode = 'off' | 'report' | 'fix';
3
+ export interface CheckLocaleParamsOptions {
4
+ appDir: string;
5
+ mode?: LocaleParamsCheckMode;
6
+ localeParam?: string;
7
+ skip?: readonly string[];
8
+ overrides?: Readonly<Record<string, {
9
+ localeParam?: string;
10
+ }>>;
11
+ verbose?: boolean | {
12
+ pageLabel?: PageLabelStyle | ((file: string, appDir: string) => string);
13
+ };
14
+ }
15
+ export interface CheckLocaleParamsReport {
16
+ file: string;
17
+ action: 'added-locale-params' | 'would-add-locale-params' | 'already-set-up' | 'needs-manual-edit' | 'skipped';
18
+ }
19
+ export interface CheckLocaleParamsIo {
20
+ findLocaleScopedFiles?: (appDir: string, localeParam: string) => string[];
21
+ readFile?: (file: string) => string;
22
+ writeFile?: (file: string, contents: string) => void;
23
+ }
24
+ export declare function checkLocaleParams(options: CheckLocaleParamsOptions, io?: CheckLocaleParamsIo): Promise<CheckLocaleParamsReport[]>;
@@ -0,0 +1,104 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { relative } from 'node:path';
3
+ import { detectLocaleParams } from './detect_locale_params.js';
4
+ import { insertLocaleParamsSignature, insertLocaleParamsBody, ensureLocaleInParamsType, addParamsPropToExistingDestructure, ensureSetLocaleImport } from './insert_locale_params.js';
5
+ import { findLocaleScopedFiles } from './find_locale_scoped_files.js';
6
+ import { deriveRoute, makePageLabeler } from '../dynamic_pages_check/derive_page_label.js';
7
+ const ZERO_ARG_DEFAULT_EXPORT = /export\s+default\s+(async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(\s*\)/;
8
+ const LEGEND = '✓ Set up + Added ? Needs manual edit · Skipped';
9
+ function actionGlyph(action) {
10
+ switch (action) {
11
+ case 'added-locale-params': return '+';
12
+ case 'would-add-locale-params': return '+';
13
+ case 'already-set-up': return '✓';
14
+ case 'needs-manual-edit': return '?';
15
+ case 'skipped': return '·';
16
+ }
17
+ }
18
+ function actionDetail(action) {
19
+ switch (action) {
20
+ case 'added-locale-params': return 'Missing locale-param setup — added it';
21
+ case 'would-add-locale-params': return 'Missing locale-param setup — would add it';
22
+ case 'already-set-up': return 'Already resolves locale from params (setLocaleAsync/setLocale)';
23
+ case 'needs-manual-edit': return 'Existing params shape not recognized — needs a manual edit';
24
+ case 'skipped': return 'Skipped — excluded from this scan';
25
+ }
26
+ }
27
+ function displayPath(file) {
28
+ const rel = relative(process.cwd(), file);
29
+ return rel === '' || rel.startsWith('..') ? file : rel;
30
+ }
31
+ function fileKind(file) {
32
+ const match = /([a-z]+)\.(?:tsx|ts|jsx|js)$/.exec(file);
33
+ return match ? match[1] : file;
34
+ }
35
+ function logReports(reports, appDir, pageLabel) {
36
+ console.log(`[cloudflare-next-intl] locale-params check\n${LEGEND}\n`);
37
+ reports.forEach((report, index) => {
38
+ const isLast = index === reports.length - 1;
39
+ const branch = isLast ? '└' : '├';
40
+ const kind = fileKind(report.file);
41
+ const route = deriveRoute(appDir, report.file) + (kind === 'page' || kind === 'route' ? '' : `/${kind}`);
42
+ console.log(`${branch} ${actionGlyph(report.action)} ${route} ${pageLabel(report.file)} [${kind}] — ${actionDetail(report.action)}`);
43
+ });
44
+ }
45
+ export async function checkLocaleParams(options, io = {}) {
46
+ const mode = options.mode ?? 'report';
47
+ if (mode === 'off')
48
+ return [];
49
+ const defaultLocaleParam = options.localeParam ?? 'locale';
50
+ const findFiles = io.findLocaleScopedFiles ?? findLocaleScopedFiles;
51
+ const readFile = io.readFile ?? ((file) => readFileSync(file, 'utf8'));
52
+ const writeFile = io.writeFile ?? ((file, contents) => writeFileSync(file, contents, 'utf8'));
53
+ const skipSet = new Set(options.skip ?? []);
54
+ const overrides = options.overrides ?? {};
55
+ const reports = [];
56
+ for (const file of findFiles(options.appDir, defaultLocaleParam)) {
57
+ if (skipSet.has(file)) {
58
+ reports.push({ file, action: 'skipped' });
59
+ continue;
60
+ }
61
+ const localeParam = overrides[file]?.localeParam ?? defaultLocaleParam;
62
+ const source = readFile(file);
63
+ const detection = detectLocaleParams(source, localeParam);
64
+ if (detection.hasLocaleParamSetup) {
65
+ reports.push({ file, action: 'already-set-up' });
66
+ continue;
67
+ }
68
+ const isZeroArg = ZERO_ARG_DEFAULT_EXPORT.test(source);
69
+ const canReuseExistingParams = detection.hasDestructuredParamsProp && !detection.hasConflictingLocaleBinding;
70
+ const canAddParamsKey = detection.hasDestructuredObjectWithoutParams && !detection.hasConflictingLocaleBinding;
71
+ if (!isZeroArg && !detection.hasInlineDestructure && !canReuseExistingParams && !canAddParamsKey) {
72
+ reports.push({ file, action: 'needs-manual-edit' });
73
+ continue;
74
+ }
75
+ if (mode === 'report') {
76
+ reports.push({ file, action: 'would-add-locale-params' });
77
+ continue;
78
+ }
79
+ let updated = source;
80
+ if (isZeroArg) {
81
+ updated = insertLocaleParamsSignature(updated, localeParam);
82
+ }
83
+ else if (canAddParamsKey) {
84
+ updated = addParamsPropToExistingDestructure(updated, localeParam);
85
+ }
86
+ else if (canReuseExistingParams && !detection.hasParamsType) {
87
+ updated = ensureLocaleInParamsType(updated, localeParam);
88
+ }
89
+ updated = insertLocaleParamsBody(updated, localeParam, detection.hasInlineDestructure);
90
+ if (updated === source) {
91
+ reports.push({ file, action: 'needs-manual-edit' });
92
+ continue;
93
+ }
94
+ updated = ensureSetLocaleImport(updated);
95
+ writeFile(file, updated);
96
+ reports.push({ file, action: 'added-locale-params' });
97
+ }
98
+ if (options.verbose) {
99
+ const pageLabelStyle = typeof options.verbose === 'object' ? options.verbose.pageLabel : undefined;
100
+ const pageLabel = makePageLabeler(options.appDir, pageLabelStyle, displayPath);
101
+ logReports(reports, options.appDir, pageLabel);
102
+ }
103
+ return reports;
104
+ }
@@ -0,0 +1,10 @@
1
+ export interface LocaleParamsDetectionResult {
2
+ hasInlineDestructure: boolean;
3
+ hasSetLocaleCall: boolean;
4
+ hasLocaleParamSetup: boolean;
5
+ hasParamsType: boolean;
6
+ hasDestructuredParamsProp: boolean;
7
+ hasConflictingLocaleBinding: boolean;
8
+ hasDestructuredObjectWithoutParams: boolean;
9
+ }
10
+ export declare function detectLocaleParams(sourceText: string, localeParam: string): LocaleParamsDetectionResult;
@@ -0,0 +1,96 @@
1
+ import { stripComments } from '../dynamic_pages_check/detect_dynamic_usage.js';
2
+ const SET_LOCALE_ASYNC_CALL = /\bsetLocaleAsync\s*\(\s*params\s*\)/;
3
+ const SET_LOCALE_CALL = /\bsetLocale(?:Cache)?\s*\(/;
4
+ function inlineDestructureRegex(localeParam) {
5
+ return new RegExp(`\\{[^}]*\\b${localeParam}\\b[^}]*\\}\\s*=\\s*await\\s+params\\b`);
6
+ }
7
+ function paramsTypeRegex(localeParam) {
8
+ return new RegExp(`params\\s*:\\s*Promise<\\{[^}]*\\b${localeParam}\\b`);
9
+ }
10
+ const DESTRUCTURED_PARAMS_PROP = /export\s+default\s+(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(\s*\{[^}]*(?<![\w$:])params(?![\w$:])[^}]*\}/;
11
+ const DEFAULT_EXPORT_FUNCTION_OPEN_PAREN = /export\s+default\s+(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(/;
12
+ function findMatchingBraceEnd(code, openBraceIndex) {
13
+ let depth = 0;
14
+ for (let i = openBraceIndex; i < code.length; i++) {
15
+ if (code[i] === '{')
16
+ depth++;
17
+ else if (code[i] === '}') {
18
+ depth--;
19
+ if (depth === 0)
20
+ return i + 1;
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+ function findDestructuredObjectWithInlineType(code) {
26
+ const openParenMatch = DEFAULT_EXPORT_FUNCTION_OPEN_PAREN.exec(code);
27
+ if (openParenMatch === null)
28
+ return null;
29
+ let i = openParenMatch.index + openParenMatch[0].length;
30
+ while (i < code.length && /\s/.test(code[i]))
31
+ i++;
32
+ if (code[i] !== '{')
33
+ return null;
34
+ const keysEnd = findMatchingBraceEnd(code, i);
35
+ if (keysEnd === null)
36
+ return null;
37
+ const keys = code.slice(i + 1, keysEnd - 1);
38
+ let j = keysEnd;
39
+ while (j < code.length && /\s/.test(code[j]))
40
+ j++;
41
+ if (code[j] !== ':')
42
+ return null;
43
+ j++;
44
+ while (j < code.length && /\s/.test(code[j]))
45
+ j++;
46
+ if (code[j] !== '{')
47
+ return null;
48
+ const typeEnd = findMatchingBraceEnd(code, j);
49
+ if (typeEnd === null)
50
+ return null;
51
+ const typeBody = code.slice(j + 1, typeEnd - 1);
52
+ return { keys, typeBody };
53
+ }
54
+ function declaredBindingNames(code) {
55
+ const names = new Set();
56
+ const declRegex = /\b(?:const|let|var)\s+([^=;]+)=/g;
57
+ let declMatch;
58
+ while ((declMatch = declRegex.exec(code)) !== null) {
59
+ const target = declMatch[1];
60
+ if (/^[A-Za-z_$][\w$]*\s*$/.test(target)) {
61
+ names.add(target.trim());
62
+ continue;
63
+ }
64
+ const braceMatch = /^\{([\s\S]*)\}\s*$/.exec(target.trim());
65
+ if (braceMatch === null)
66
+ continue;
67
+ for (const part of braceMatch[1].split(',')) {
68
+ const key = part.split(':')[0].trim();
69
+ if (/^[A-Za-z_$][\w$]*$/.test(key))
70
+ names.add(key);
71
+ }
72
+ }
73
+ return names;
74
+ }
75
+ export function detectLocaleParams(sourceText, localeParam) {
76
+ const code = stripComments(sourceText);
77
+ const hasSetLocaleAsync = SET_LOCALE_ASYNC_CALL.test(code);
78
+ const hasInlineDestructure = inlineDestructureRegex(localeParam).test(code);
79
+ const hasSetLocaleCall = hasSetLocaleAsync || SET_LOCALE_CALL.test(code);
80
+ const hasConflictingLocaleBinding = !hasInlineDestructure && declaredBindingNames(code).has(localeParam);
81
+ const hasDestructuredParamsProp = DESTRUCTURED_PARAMS_PROP.test(code);
82
+ const destructuredObject = findDestructuredObjectWithInlineType(code);
83
+ const hasAnyParamsKey = /(?<![\w$])params(?![\w$])/.test(destructuredObject?.keys ?? '');
84
+ const hasDestructuredObjectWithoutParams = !hasDestructuredParamsProp
85
+ && destructuredObject !== null
86
+ && !hasAnyParamsKey;
87
+ return {
88
+ hasInlineDestructure,
89
+ hasSetLocaleCall,
90
+ hasLocaleParamSetup: hasSetLocaleAsync || (hasInlineDestructure && hasSetLocaleCall),
91
+ hasParamsType: paramsTypeRegex(localeParam).test(code),
92
+ hasDestructuredParamsProp,
93
+ hasConflictingLocaleBinding,
94
+ hasDestructuredObjectWithoutParams,
95
+ };
96
+ }
@@ -0,0 +1 @@
1
+ export declare function findLocaleScopedFiles(appDir: string, localeParam: string): string[];
@@ -0,0 +1,14 @@
1
+ import { sep } from 'node:path';
2
+ import { findPageFiles } from '../dynamic_pages_check/find_page_files.js';
3
+ const LOCALE_SCOPED_FILE_NAMES = new Set([
4
+ 'page.tsx', 'page.ts', 'page.jsx', 'page.js',
5
+ 'layout.tsx', 'layout.ts', 'layout.jsx', 'layout.js',
6
+ 'loading.tsx', 'loading.ts', 'loading.jsx', 'loading.js',
7
+ ]);
8
+ export function findLocaleScopedFiles(appDir, localeParam) {
9
+ const prefix = `${appDir}${sep}[${localeParam}]${sep}`;
10
+ return findPageFiles(appDir).filter((file) => {
11
+ const name = file.slice(file.lastIndexOf(sep) + 1);
12
+ return LOCALE_SCOPED_FILE_NAMES.has(name) && file.startsWith(prefix);
13
+ });
14
+ }
@@ -0,0 +1,4 @@
1
+ export { checkLocaleParams, type LocaleParamsCheckMode, type CheckLocaleParamsOptions, type CheckLocaleParamsReport, type CheckLocaleParamsIo } from './check_locale_params.js';
2
+ export { findLocaleScopedFiles } from './find_locale_scoped_files.js';
3
+ export { detectLocaleParams, type LocaleParamsDetectionResult } from './detect_locale_params.js';
4
+ export { insertLocaleParamsSignature, insertLocaleParamsBody, ensureLocaleInParamsType, addParamsPropToExistingDestructure, ensureSetLocaleImport } from './insert_locale_params.js';
@@ -0,0 +1,4 @@
1
+ export { checkLocaleParams } from './check_locale_params.js';
2
+ export { findLocaleScopedFiles } from './find_locale_scoped_files.js';
3
+ export { detectLocaleParams } from './detect_locale_params.js';
4
+ export { insertLocaleParamsSignature, insertLocaleParamsBody, ensureLocaleInParamsType, addParamsPropToExistingDestructure, ensureSetLocaleImport } from './insert_locale_params.js';
@@ -0,0 +1,5 @@
1
+ export declare function insertLocaleParamsSignature(sourceText: string, localeParam: string): string;
2
+ export declare function addParamsPropToExistingDestructure(sourceText: string, localeParam: string): string;
3
+ export declare function insertLocaleParamsBody(sourceText: string, localeParam: string, hasInlineDestructure: boolean): string;
4
+ export declare function ensureLocaleInParamsType(sourceText: string, localeParam: string): string;
5
+ export declare function ensureSetLocaleImport(sourceText: string): string;
@@ -0,0 +1,117 @@
1
+ const ZERO_ARG_DEFAULT_EXPORT = /export\s+default\s+(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*(?=\(\s*\))/;
2
+ const ZERO_ARG_PARENS = /\(\s*\)/;
3
+ function findFunctionBodyStart(sourceText) {
4
+ const match = /export\s+default\s+(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\([^)]*\)[^{]*\{/.exec(sourceText);
5
+ if (match === null)
6
+ return null;
7
+ return match.index + match[0].length;
8
+ }
9
+ export function insertLocaleParamsSignature(sourceText, localeParam) {
10
+ const nameMatch = ZERO_ARG_DEFAULT_EXPORT.exec(sourceText);
11
+ if (nameMatch === null)
12
+ return sourceText;
13
+ const parensMatch = ZERO_ARG_PARENS.exec(sourceText.slice(nameMatch.index + nameMatch[0].length));
14
+ if (parensMatch === null)
15
+ return sourceText;
16
+ const parensStart = nameMatch.index + nameMatch[0].length + parensMatch.index;
17
+ const parensEnd = parensStart + parensMatch[0].length;
18
+ const replacement = `({ params }: {\n params: Promise<{ ${localeParam}: Language }>;\n})`;
19
+ return sourceText.slice(0, parensStart) + replacement + sourceText.slice(parensEnd);
20
+ }
21
+ const DEFAULT_EXPORT_FUNCTION_OPEN_PAREN = /export\s+default\s+(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(/;
22
+ function findMatchingBraceEnd(code, openBraceIndex) {
23
+ let depth = 0;
24
+ for (let i = openBraceIndex; i < code.length; i++) {
25
+ if (code[i] === '{')
26
+ depth++;
27
+ else if (code[i] === '}') {
28
+ depth--;
29
+ if (depth === 0)
30
+ return i + 1;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ function findDestructuredObjectSpans(sourceText) {
36
+ const openParenMatch = DEFAULT_EXPORT_FUNCTION_OPEN_PAREN.exec(sourceText);
37
+ if (openParenMatch === null)
38
+ return null;
39
+ let i = openParenMatch.index + openParenMatch[0].length;
40
+ while (i < sourceText.length && /\s/.test(sourceText[i]))
41
+ i++;
42
+ if (sourceText[i] !== '{')
43
+ return null;
44
+ const keysBraceEnd = findMatchingBraceEnd(sourceText, i);
45
+ if (keysBraceEnd === null)
46
+ return null;
47
+ let j = keysBraceEnd;
48
+ while (j < sourceText.length && /\s/.test(sourceText[j]))
49
+ j++;
50
+ if (sourceText[j] !== ':')
51
+ return null;
52
+ j++;
53
+ while (j < sourceText.length && /\s/.test(sourceText[j]))
54
+ j++;
55
+ if (sourceText[j] !== '{')
56
+ return null;
57
+ const typeBraceEnd = findMatchingBraceEnd(sourceText, j);
58
+ if (typeBraceEnd === null)
59
+ return null;
60
+ return { keysStart: i + 1, keysEnd: keysBraceEnd - 1, typeStart: j + 1, typeEnd: typeBraceEnd - 1 };
61
+ }
62
+ export function addParamsPropToExistingDestructure(sourceText, localeParam) {
63
+ const spans = findDestructuredObjectSpans(sourceText);
64
+ if (spans === null)
65
+ return sourceText;
66
+ const { keysStart, keysEnd, typeStart, typeEnd } = spans;
67
+ const keys = sourceText.slice(keysStart, keysEnd);
68
+ const typeBody = sourceText.slice(typeStart, typeEnd);
69
+ const keysTrimmedEnd = keysStart + keys.replace(/\s+$/, '').length;
70
+ const typeTrimmedEnd = typeStart + typeBody.replace(/\s+$/, '').length;
71
+ const keysSeparator = keys.trim() === '' || /,\s*$/.test(sourceText.slice(keysStart, keysTrimmedEnd)) ? '' : ',';
72
+ const typeSeparator = typeBody.trim() === '' || /;\s*$/.test(sourceText.slice(typeStart, typeTrimmedEnd)) ? '' : ';';
73
+ let result = `${sourceText.slice(0, typeTrimmedEnd)}${typeSeparator} params: Promise<{ ${localeParam}: Language }>; ${sourceText.slice(typeEnd)}`;
74
+ result = `${result.slice(0, keysTrimmedEnd)}${keysSeparator} params ${result.slice(keysEnd)}`;
75
+ return result;
76
+ }
77
+ export function insertLocaleParamsBody(sourceText, localeParam, hasInlineDestructure) {
78
+ if (hasInlineDestructure) {
79
+ const destructureRegex = new RegExp(`(\\{[^}]*\\b${localeParam}\\b[^}]*\\}\\s*=\\s*await\\s+params\\s*;)`);
80
+ const destructureMatch = destructureRegex.exec(sourceText);
81
+ if (destructureMatch === null)
82
+ return sourceText;
83
+ const at = destructureMatch.index + destructureMatch[0].length;
84
+ return `${sourceText.slice(0, at)}\n setLocale(${localeParam});${sourceText.slice(at)}`;
85
+ }
86
+ const bodyStart = findFunctionBodyStart(sourceText);
87
+ if (bodyStart === null)
88
+ return sourceText;
89
+ const line = `\n const { ${localeParam} } = await params;\n setLocale(${localeParam});\n`;
90
+ return sourceText.slice(0, bodyStart) + line + sourceText.slice(bodyStart);
91
+ }
92
+ const PARAMS_PROMISE_TYPE = /params\s*:\s*Promise<\{([^}]*)\}>/;
93
+ export function ensureLocaleInParamsType(sourceText, localeParam) {
94
+ const match = PARAMS_PROMISE_TYPE.exec(sourceText);
95
+ if (match === null)
96
+ return sourceText;
97
+ const inner = match[1];
98
+ if (new RegExp(`\\b${localeParam}\\b`).test(inner))
99
+ return sourceText;
100
+ const contentStart = match.index + match[0].indexOf('{') + 1;
101
+ const trimmedLength = inner.replace(/\s+$/, '').length;
102
+ const insertAt = contentStart + trimmedLength;
103
+ const separator = /;\s*$/.test(inner.slice(0, trimmedLength)) || inner.trim() === '' ? '' : ';';
104
+ return `${sourceText.slice(0, insertAt)}${separator} ${localeParam}: Language ${sourceText.slice(insertAt + (inner.length - trimmedLength))}`;
105
+ }
106
+ const CLOUDFLARE_NEXT_INTL_IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]cloudflare-next-intl['"]\s*;?/;
107
+ export function ensureSetLocaleImport(sourceText) {
108
+ const match = CLOUDFLARE_NEXT_INTL_IMPORT.exec(sourceText);
109
+ if (match === null) {
110
+ return `import { setLocale } from "cloudflare-next-intl";\n${sourceText}`;
111
+ }
112
+ const names = match[1];
113
+ if (/\bsetLocale\b/.test(names))
114
+ return sourceText;
115
+ const replacement = match[0].replace(names, `${names.replace(/\s*$/, '')}, setLocale `);
116
+ return sourceText.slice(0, match.index) + replacement + sourceText.slice(match.index + match[0].length);
117
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { checkDynamicPages } from "../dynamic_pages_check/index.js";
4
+ import { registerBuildWriteRestore } from "./build_write_restore_stack.js";
4
5
  const RESTORABLE_ACTIONS = new Set(['added-force-dynamic', 'added-force-static']);
5
6
  export function autoDynamicPagesPlugin(options = {}) {
6
7
  let ran = false;
@@ -59,8 +60,7 @@ export function autoDynamicPagesPlugin(options = {}) {
59
60
  if (!restorable.has(file))
60
61
  originals.delete(file);
61
62
  }
62
- if (originals.size > 0)
63
- registerRestore(originals);
63
+ registerBuildWriteRestore(originals);
64
64
  }
65
65
  catch (err) {
66
66
  console.warn("[cloudflare-next-intl] autoDynamicPages check error:", err);
@@ -68,25 +68,3 @@ export function autoDynamicPagesPlugin(options = {}) {
68
68
  },
69
69
  };
70
70
  }
71
- function registerRestore(originals) {
72
- let restored = false;
73
- const restore = () => {
74
- if (restored)
75
- return;
76
- restored = true;
77
- for (const [file, contents] of originals) {
78
- try {
79
- writeFileSync(file, contents, "utf8");
80
- }
81
- catch {
82
- }
83
- }
84
- };
85
- process.once("exit", restore);
86
- for (const signal of ["SIGINT", "SIGTERM"]) {
87
- process.once(signal, () => {
88
- restore();
89
- process.kill(process.pid, signal);
90
- });
91
- }
92
- }
@@ -0,0 +1,18 @@
1
+ import type { Plugin } from "vite";
2
+ import { type LocaleParamsCheckMode } from "../locale_params_check/check_locale_params.js";
3
+ import type { PageLabelStyle } from "../dynamic_pages_check/derive_page_label.js";
4
+ export interface AutoLocaleParamsPluginOptions {
5
+ appDir?: string;
6
+ mode?: LocaleParamsCheckMode;
7
+ localeParam?: string;
8
+ skip?: readonly string[];
9
+ overrides?: Readonly<Record<string, {
10
+ localeParam?: string;
11
+ }>>;
12
+ runOnDev?: boolean;
13
+ restoreAfterBuild?: boolean;
14
+ verbose?: boolean | {
15
+ pageLabel?: PageLabelStyle | ((file: string, appDir: string) => string);
16
+ };
17
+ }
18
+ export declare function autoLocaleParamsPlugin(options?: AutoLocaleParamsPluginOptions): Plugin;