cloudflare-next-intl 0.9.41 → 0.9.44

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
@@ -261,7 +261,7 @@ 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 & Dev 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.
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
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
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
267
  5. **Cloudflare Workers Client Stub (`cfWorkersClientStub`)**: Stubs `cloudflare:workers` in client builds so shared modules can be referenced without client bundling errors.
@@ -278,6 +278,7 @@ export default defineConfig({
278
278
  plugins: [
279
279
  cloudflareNextIntl({
280
280
  imageOptimizer: { // Image optimizer configuration (or `false` to disable)
281
+ dev: false, // Also run the scan on dev server start, not just production build (default: false)
281
282
  maxWidth: 1920, // Downscale max width limit (default: 1920, or `false`)
282
283
  formats: ["avif", "webp"], // Target sibling formats, in browser-preference order (default: ["webp"], or `false`).
283
284
  // Also supports: "png", "jpeg", "gif", "tiff", "heif", "jp2", "jxl"
@@ -1,4 +1,7 @@
1
+ import { type DynamicApiCheck } from './detect_dynamic_usage.js';
2
+ import { type DynamicSignal } from './trace_dynamic_usage.js';
1
3
  import { type SyncErrorReportingAuthUserReport } from './sync_error_reporting_auth_user.js';
4
+ import { type PageLabelStyle } from './derive_page_label.js';
2
5
  import type { AliasConfig } from './resolve_local_imports.js';
3
6
  export type DynamicPagesCheckMode = 'off' | 'report' | 'fix';
4
7
  export interface CheckDynamicPagesOptions {
@@ -8,11 +11,17 @@ export interface CheckDynamicPagesOptions {
8
11
  skip?: readonly string[];
9
12
  resolveImports?: boolean;
10
13
  aliases?: readonly AliasConfig[];
14
+ extraChecks?: readonly DynamicApiCheck[];
11
15
  syncErrorReportingAuthUser?: boolean;
16
+ verbose?: boolean | {
17
+ pageLabel?: PageLabelStyle | ((file: string, appDir: string) => string);
18
+ };
12
19
  }
13
20
  export interface CheckDynamicPagesReport {
14
21
  file: string;
15
22
  action: 'added-force-dynamic' | 'would-add-force-dynamic' | 'added-force-static' | 'would-add-force-static' | 'already-declared' | 'no-dynamic-usage-detected' | 'skipped';
23
+ signals?: DynamicSignal[];
24
+ explicitValue?: 'force-static' | 'force-dynamic' | 'auto' | 'error' | null;
16
25
  }
17
26
  export interface CheckDynamicPagesIo {
18
27
  findPageFiles?: (appDir: string) => string[];
@@ -1,10 +1,73 @@
1
1
  import { readFileSync, statSync, writeFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { relative, resolve } from 'node:path';
3
3
  import { findPageFiles as findPageFilesImpl } from './find_page_files.js';
4
- import { detectDynamicUsage } from './detect_dynamic_usage.js';
4
+ import { detectDynamicUsage, readExplicitDynamicValue } from './detect_dynamic_usage.js';
5
5
  import { traceDynamicUsage } from './trace_dynamic_usage.js';
6
6
  import { insertDynamicExport } from './insert_dynamic_export.js';
7
7
  import { syncErrorReportingAuthUser } from './sync_error_reporting_auth_user.js';
8
+ import { deriveRoute, isApiRoute, makePageLabeler } from './derive_page_label.js';
9
+ const LEGEND = 'λ API ƒ Dynamic (SSR) ○ Static (SSG) = Already declared - Unclear (framework decides) · Skipped';
10
+ function actionGlyph(report, isApi) {
11
+ if (isApi && report.action !== 'skipped')
12
+ return 'λ';
13
+ switch (report.action) {
14
+ case 'added-force-dynamic':
15
+ case 'would-add-force-dynamic':
16
+ return 'ƒ';
17
+ case 'added-force-static':
18
+ case 'would-add-force-static':
19
+ return '○';
20
+ case 'no-dynamic-usage-detected': return '-';
21
+ case 'skipped': return '·';
22
+ case 'already-declared':
23
+ if (report.explicitValue === 'force-dynamic')
24
+ return 'ƒ';
25
+ if (report.explicitValue === 'force-static')
26
+ return '○';
27
+ return '=';
28
+ }
29
+ }
30
+ function actionDetail(report, isApi) {
31
+ if (isApi && report.action !== 'skipped')
32
+ return 'API route';
33
+ switch (report.action) {
34
+ case 'added-force-dynamic': return 'Dynamic (SSR) — added export const dynamic = "force-dynamic"';
35
+ case 'would-add-force-dynamic': return 'Dynamic (SSR) — would add export const dynamic = "force-dynamic"';
36
+ case 'added-force-static': return 'Static (SSG) — added export const dynamic = "force-static"';
37
+ case 'would-add-force-static': return 'Static (SSG) — would add export const dynamic = "force-static"';
38
+ case 'no-dynamic-usage-detected': return 'Unclear — no dynamic-API usage detected, left to the framework';
39
+ case 'skipped': return 'Skipped — excluded from this scan';
40
+ case 'already-declared':
41
+ switch (report.explicitValue) {
42
+ case 'force-dynamic': return 'Dynamic (SSR) — export const dynamic = "force-dynamic" already set';
43
+ case 'force-static': return 'Static (SSG) — export const dynamic = "force-static" already set';
44
+ case 'auto': return 'export const dynamic = "auto" already set';
45
+ case 'error': return 'export const dynamic = "error" already set';
46
+ default: return 'export const dynamic already set';
47
+ }
48
+ }
49
+ }
50
+ function displayPath(file) {
51
+ const rel = relative(process.cwd(), file);
52
+ return rel === '' || rel.startsWith('..') ? file : rel;
53
+ }
54
+ function logReports(reports, appDir, pageLabel) {
55
+ console.log(`[cloudflare-next-intl] dynamic-pages check\n${LEGEND}\n`);
56
+ reports.forEach((report, index) => {
57
+ const isLast = index === reports.length - 1;
58
+ const branch = isLast ? '└' : '├';
59
+ const isApi = isApiRoute(report.file);
60
+ const glyph = actionGlyph(report, isApi);
61
+ const route = deriveRoute(appDir, report.file);
62
+ console.log(`${branch} ${glyph} ${route} ${pageLabel(report.file)} — ${actionDetail(report, isApi)}`);
63
+ const continuation = isLast ? ' ' : '│';
64
+ for (const signal of report.signals ?? []) {
65
+ const location = `${displayPath(signal.file)}:${signal.line}`;
66
+ const where = signal.file === report.file ? `at ${location}` : `via ${location}`;
67
+ console.log(`${continuation} ↳ ${signal.api} ${where}`);
68
+ }
69
+ });
70
+ }
8
71
  function defaultIsFile(path) {
9
72
  try {
10
73
  return statSync(path).isFile();
@@ -27,6 +90,7 @@ export async function checkDynamicPages(options, io = {}) {
27
90
  const aliases = options.aliases ?? [
28
91
  { prefix: '@/', replacement: resolve(options.appDir, '..') },
29
92
  ];
93
+ const extraChecks = options.extraChecks ?? [];
30
94
  const reports = [];
31
95
  for (const file of findPageFiles(options.appDir)) {
32
96
  if (skipSet.has(file)) {
@@ -35,10 +99,13 @@ export async function checkDynamicPages(options, io = {}) {
35
99
  }
36
100
  const source = readFile(file);
37
101
  const detection = resolveImports
38
- ? traceDynamicUsage(file, source, aliases, { readFile, isFile })
39
- : detectDynamicUsage(source);
102
+ ? traceDynamicUsage(file, source, aliases, { readFile, isFile }, extraChecks)
103
+ : { ...detectDynamicUsage(source, extraChecks), signals: [] };
104
+ const signals = resolveImports
105
+ ? detection.signals
106
+ : detection.matches.map(({ name, line }) => ({ api: name, file, line }));
40
107
  if (detection.hasExplicitDynamicExport) {
41
- reports.push({ file, action: 'already-declared' });
108
+ reports.push({ file, action: 'already-declared', explicitValue: readExplicitDynamicValue(source) });
42
109
  continue;
43
110
  }
44
111
  if (detection.detectedDynamicApis.length === 0) {
@@ -57,12 +124,17 @@ export async function checkDynamicPages(options, io = {}) {
57
124
  }
58
125
  if (mode === 'fix') {
59
126
  writeFile(file, insertDynamicExport(source, 'force-dynamic'));
60
- reports.push({ file, action: 'added-force-dynamic' });
127
+ reports.push({ file, action: 'added-force-dynamic', signals });
61
128
  }
62
129
  else {
63
- reports.push({ file, action: 'would-add-force-dynamic' });
130
+ reports.push({ file, action: 'would-add-force-dynamic', signals });
64
131
  }
65
132
  }
133
+ if (options.verbose) {
134
+ const pageLabelStyle = typeof options.verbose === 'object' ? options.verbose.pageLabel : undefined;
135
+ const pageLabel = makePageLabeler(options.appDir, pageLabelStyle, displayPath);
136
+ logReports(reports, options.appDir, pageLabel);
137
+ }
66
138
  if (options.syncErrorReportingAuthUser === true) {
67
139
  const syncReports = await syncErrorReportingAuthUser({ appDir: options.appDir, mode: options.mode, target: options.target, skip: options.skip, aliases: options.aliases }, { findPageFiles, readFile, writeFile, isFile });
68
140
  reports.push(...syncReports);
@@ -1,5 +1,16 @@
1
- import { extractImportSpecifiers, resolveLocalImport } from './resolve_local_imports.js';
1
+ import { extractImportBindings, resolveLocalImport } from './resolve_local_imports.js';
2
+ import { stripComments, USE_CLIENT_DIRECTIVE } from './detect_dynamic_usage.js';
2
3
  export const MAX_FILES_VISITED = 300;
4
+ function blankSpans(code, spans) {
5
+ let out = code;
6
+ for (const { start, end } of spans) {
7
+ out = out.slice(0, start) + [...out.slice(start, end)].map((c) => (c === '\n' ? '\n' : ' ')).join('') + out.slice(end);
8
+ }
9
+ return out;
10
+ }
11
+ function isWordUsed(name, text) {
12
+ return new RegExp(`\\b${name}\\b`).test(text);
13
+ }
3
14
  export function collectReachableFiles(entryFile, entrySource, aliases, io) {
4
15
  const isFile = io.isFile ?? (() => false);
5
16
  const files = new Map([[entryFile, entrySource]]);
@@ -9,9 +20,17 @@ export function collectReachableFiles(entryFile, entrySource, aliases, io) {
9
20
  if (files.size >= MAX_FILES_VISITED)
10
21
  continue;
11
22
  const source = files.get(current);
12
- for (const specifier of extractImportSpecifiers(source)) {
23
+ if (USE_CLIENT_DIRECTIVE.test(source))
24
+ continue;
25
+ const code = stripComments(source);
26
+ const imports = extractImportBindings(code);
27
+ const usageText = blankSpans(code, imports);
28
+ for (const { specifier, bindings, alwaysFollow } of imports) {
13
29
  if (files.size >= MAX_FILES_VISITED)
14
30
  break;
31
+ if (!alwaysFollow && bindings.length > 0 && !bindings.some((name) => isWordUsed(name, usageText))) {
32
+ continue;
33
+ }
15
34
  const resolved = resolveLocalImport(specifier, current, aliases, isFile);
16
35
  if (resolved === null || files.has(resolved))
17
36
  continue;
@@ -0,0 +1,5 @@
1
+ export declare function derivePageLabel(appDir: string, file: string): string;
2
+ export declare function deriveRoute(appDir: string, file: string): string;
3
+ export declare function isApiRoute(file: string): boolean;
4
+ export type PageLabelStyle = 'title' | 'path';
5
+ export declare function makePageLabeler(appDir: string, style: PageLabelStyle | ((file: string, appDir: string) => string) | undefined, displayPath: (file: string) => string): (file: string) => string;
@@ -0,0 +1,57 @@
1
+ import { relative, sep } from 'node:path';
2
+ function splitWords(segment) {
3
+ return segment
4
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
5
+ .split(/[-_\s]+/)
6
+ .filter((word) => word.length > 0);
7
+ }
8
+ function titleCaseWords(words) {
9
+ return words.map((word) => word[0].toUpperCase() + word.slice(1)).join(' ');
10
+ }
11
+ export function derivePageLabel(appDir, file) {
12
+ const rel = relative(appDir, file);
13
+ const segments = rel.split(sep).filter((s) => s.length > 0);
14
+ segments.pop();
15
+ let literal = null;
16
+ let dynamicSuffix = null;
17
+ for (const segment of segments) {
18
+ const dynamicMatch = /^\[+\.{0,3}([^\]]+)\]+$/.exec(segment);
19
+ if (dynamicMatch) {
20
+ if (literal !== null)
21
+ dynamicSuffix = dynamicMatch[1];
22
+ continue;
23
+ }
24
+ if (/^\(.+\)$/.test(segment))
25
+ continue;
26
+ literal = segment;
27
+ dynamicSuffix = null;
28
+ }
29
+ if (literal === null)
30
+ return 'Home';
31
+ const label = titleCaseWords(splitWords(literal));
32
+ return dynamicSuffix ? `${label} (:${dynamicSuffix})` : label;
33
+ }
34
+ export function deriveRoute(appDir, file) {
35
+ const rel = relative(appDir, file);
36
+ const segments = rel.split(sep).filter((s) => s.length > 0);
37
+ segments.pop();
38
+ const urlSegments = segments
39
+ .filter((segment) => !/^\(.+\)$/.test(segment))
40
+ .map((segment) => {
41
+ const dynamicMatch = /^\[+(\.{3})?([^\]]+)\]+$/.exec(segment);
42
+ if (!dynamicMatch)
43
+ return segment;
44
+ return `:${dynamicMatch[1] ? '...' : ''}${dynamicMatch[2]}`;
45
+ });
46
+ return urlSegments.length > 0 ? `/${urlSegments.join('/')}` : '/';
47
+ }
48
+ export function isApiRoute(file) {
49
+ return /(^|[\\/])route\.(ts|js)$/.test(file);
50
+ }
51
+ export function makePageLabeler(appDir, style, displayPath) {
52
+ if (typeof style === 'function')
53
+ return (file) => style(file, appDir);
54
+ if (style === 'path')
55
+ return displayPath;
56
+ return (file) => derivePageLabel(appDir, file);
57
+ }
@@ -1,6 +1,17 @@
1
+ export interface DynamicApiMatch {
2
+ name: string;
3
+ line: number;
4
+ }
1
5
  export interface DynamicDetectionResult {
2
6
  hasExplicitDynamicExport: boolean;
3
7
  detectedDynamicApis: string[];
8
+ matches: DynamicApiMatch[];
9
+ }
10
+ export declare function stripComments(sourceText: string): string;
11
+ export interface DynamicApiCheck {
12
+ name: string;
13
+ pattern: RegExp;
4
14
  }
5
- export declare function detectDynamicUsage(sourceText: string): DynamicDetectionResult;
15
+ export declare const USE_CLIENT_DIRECTIVE: RegExp;
16
+ export declare function detectDynamicUsage(sourceText: string, extraChecks?: readonly DynamicApiCheck[]): DynamicDetectionResult;
6
17
  export declare function readExplicitDynamicValue(sourceText: string): 'force-static' | 'force-dynamic' | 'auto' | 'error' | null;
@@ -1,3 +1,34 @@
1
+ function lineOf(sourceText, index) {
2
+ let line = 1;
3
+ for (let i = 0; i < index; i++) {
4
+ if (sourceText.charCodeAt(i) === 10)
5
+ line++;
6
+ }
7
+ return line;
8
+ }
9
+ export function stripComments(sourceText) {
10
+ let out = '';
11
+ for (let i = 0; i < sourceText.length; i++) {
12
+ if (sourceText[i] === '/' && sourceText[i + 1] === '*') {
13
+ const end = sourceText.indexOf('*/', i + 2);
14
+ const commentEnd = end === -1 ? sourceText.length : end + 2;
15
+ for (let j = i; j < commentEnd; j++)
16
+ out += sourceText[j] === '\n' ? '\n' : ' ';
17
+ i = commentEnd - 1;
18
+ continue;
19
+ }
20
+ if (sourceText[i] === '/' && sourceText[i + 1] === '/' && sourceText[i - 1] !== ':') {
21
+ let end = sourceText.indexOf('\n', i);
22
+ if (end === -1)
23
+ end = sourceText.length;
24
+ out += ' '.repeat(end - i);
25
+ i = end - 1;
26
+ continue;
27
+ }
28
+ out += sourceText[i];
29
+ }
30
+ return out;
31
+ }
1
32
  const DYNAMIC_API_CHECKS = [
2
33
  { name: 'cookies()', pattern: /\bcookies\s*\(/ },
3
34
  { name: 'headers()', pattern: /\bheaders\s*\(\s*\)/ },
@@ -10,21 +41,31 @@ const DYNAMIC_API_CHECKS = [
10
41
  { name: 'withUserDb()', pattern: /\bwithUserDb\s*\(/ },
11
42
  ];
12
43
  const USE_AUTH_USER_CALL = /\buseAuthUser\s*\(/;
13
- const USE_CLIENT_DIRECTIVE = /^(?:\s*['"]use \w[\w-]*['"]\s*;?\s*)*['"]use client['"]\s*;?/;
44
+ export const USE_CLIENT_DIRECTIVE = /^(?:\s*['"]use \w[\w-]*['"]\s*;?\s*)*['"]use client['"]\s*;?/;
14
45
  const EXPLICIT_DYNAMIC_EXPORT = /export\s+const\s+dynamic\s*=/;
15
- export function detectDynamicUsage(sourceText) {
16
- const detectedDynamicApis = DYNAMIC_API_CHECKS.filter(({ pattern }) => pattern.test(sourceText)).map(({ name }) => name);
17
- if (USE_AUTH_USER_CALL.test(sourceText) && !USE_CLIENT_DIRECTIVE.test(sourceText)) {
18
- detectedDynamicApis.push('useAuthUser()');
46
+ export function detectDynamicUsage(sourceText, extraChecks = []) {
47
+ const code = stripComments(sourceText);
48
+ const matches = [];
49
+ for (const { name, pattern } of [...DYNAMIC_API_CHECKS, ...extraChecks]) {
50
+ pattern.lastIndex = 0;
51
+ const found = pattern.exec(code);
52
+ if (found !== null)
53
+ matches.push({ name, line: lineOf(sourceText, found.index) });
54
+ }
55
+ if (!USE_CLIENT_DIRECTIVE.test(sourceText)) {
56
+ const found = USE_AUTH_USER_CALL.exec(code);
57
+ if (found !== null)
58
+ matches.push({ name: 'useAuthUser()', line: lineOf(sourceText, found.index) });
19
59
  }
20
60
  return {
21
- hasExplicitDynamicExport: EXPLICIT_DYNAMIC_EXPORT.test(sourceText),
22
- detectedDynamicApis,
61
+ hasExplicitDynamicExport: EXPLICIT_DYNAMIC_EXPORT.test(code),
62
+ detectedDynamicApis: matches.map((m) => m.name),
63
+ matches,
23
64
  };
24
65
  }
25
66
  const EXPLICIT_DYNAMIC_EXPORT_VALUE = /export\s+const\s+dynamic\s*=\s*['"]([^'"]+)['"]/;
26
67
  export function readExplicitDynamicValue(sourceText) {
27
- const match = EXPLICIT_DYNAMIC_EXPORT_VALUE.exec(sourceText);
68
+ const match = EXPLICIT_DYNAMIC_EXPORT_VALUE.exec(stripComments(sourceText));
28
69
  if (match === null)
29
70
  return null;
30
71
  const value = match[1];
@@ -1,3 +1,6 @@
1
1
  export { checkDynamicPages, type DynamicPagesCheckMode, type CheckDynamicPagesOptions, type CheckDynamicPagesReport, type CheckDynamicPagesIo } from './check_dynamic_pages.js';
2
2
  export { findPageFiles } from './find_page_files.js';
3
- export { detectDynamicUsage, type DynamicDetectionResult } from './detect_dynamic_usage.js';
3
+ export { detectDynamicUsage, stripComments, type DynamicApiCheck, type DynamicApiMatch, type DynamicDetectionResult } from './detect_dynamic_usage.js';
4
+ export { traceDynamicUsage, type DynamicSignal, type TraceDynamicUsageResult } from './trace_dynamic_usage.js';
5
+ export { collectReachableFiles, MAX_FILES_VISITED } from './collect_reachable_files.js';
6
+ export { derivePageLabel, deriveRoute, isApiRoute, makePageLabeler, type PageLabelStyle } from './derive_page_label.js';
@@ -1,3 +1,6 @@
1
1
  export { checkDynamicPages } from './check_dynamic_pages.js';
2
2
  export { findPageFiles } from './find_page_files.js';
3
- export { detectDynamicUsage } from './detect_dynamic_usage.js';
3
+ export { detectDynamicUsage, stripComments } from './detect_dynamic_usage.js';
4
+ export { traceDynamicUsage } from './trace_dynamic_usage.js';
5
+ export { collectReachableFiles, MAX_FILES_VISITED } from './collect_reachable_files.js';
6
+ export { derivePageLabel, deriveRoute, isApiRoute, makePageLabeler } from './derive_page_label.js';
@@ -3,4 +3,12 @@ export interface AliasConfig {
3
3
  replacement: string;
4
4
  }
5
5
  export declare function extractImportSpecifiers(sourceText: string): string[];
6
+ export interface ImportBindingInfo {
7
+ specifier: string;
8
+ bindings: string[];
9
+ alwaysFollow: boolean;
10
+ start: number;
11
+ end: number;
12
+ }
13
+ export declare function extractImportBindings(sourceText: string): ImportBindingInfo[];
6
14
  export declare function resolveLocalImport(specifier: string, fromFile: string, aliases: readonly AliasConfig[], isFile?: (file: string) => boolean): string | null;
@@ -15,6 +15,62 @@ export function extractImportSpecifiers(sourceText) {
15
15
  }
16
16
  return specifiers;
17
17
  }
18
+ const FROM_IMPORT_STATEMENT = /\b(import|export)\s+type\s+|\b(import|export)\s+([\s\S]*?)\s*from\s*(['"])([^'"]+)\4/g;
19
+ const BARE_IMPORT_STATEMENT = /(?:^|\n|;)\s*(import\s*(['"])([^'"]+)\2)/g;
20
+ function bindingsFromClause(clause) {
21
+ const bindings = [];
22
+ const namespaceMatch = /^\*\s*as\s+(\w+)$/.exec(clause.trim());
23
+ if (namespaceMatch)
24
+ return [namespaceMatch[1]];
25
+ if (clause.trim() === '*')
26
+ return [];
27
+ const braceMatch = /\{([^}]*)\}/.exec(clause);
28
+ if (braceMatch) {
29
+ for (const rawItem of braceMatch[1].split(',')) {
30
+ const item = rawItem.trim().replace(/^type\s+/, '');
31
+ if (item.length === 0)
32
+ continue;
33
+ const asMatch = /\bas\s+(\w+)$/.exec(item);
34
+ bindings.push(asMatch ? asMatch[1] : item);
35
+ }
36
+ }
37
+ const beforeBrace = clause.slice(0, braceMatch?.index ?? clause.length).replace(/,\s*$/, '').trim();
38
+ if (/^\w+$/.test(beforeBrace))
39
+ bindings.push(beforeBrace);
40
+ return bindings;
41
+ }
42
+ export function extractImportBindings(sourceText) {
43
+ const results = [];
44
+ FROM_IMPORT_STATEMENT.lastIndex = 0;
45
+ let match;
46
+ while ((match = FROM_IMPORT_STATEMENT.exec(sourceText)) !== null) {
47
+ if (match[1] !== undefined || match[2] === undefined)
48
+ continue;
49
+ const keyword = match[2];
50
+ const clause = match[3];
51
+ const specifier = match[5];
52
+ results.push({
53
+ specifier,
54
+ bindings: keyword === 'export' ? [] : bindingsFromClause(clause),
55
+ alwaysFollow: keyword === 'export',
56
+ start: match.index,
57
+ end: match.index + match[0].length,
58
+ });
59
+ }
60
+ BARE_IMPORT_STATEMENT.lastIndex = 0;
61
+ while ((match = BARE_IMPORT_STATEMENT.exec(sourceText)) !== null) {
62
+ const statement = match[1];
63
+ const statementStart = match.index + match[0].indexOf(statement);
64
+ results.push({
65
+ specifier: match[3],
66
+ bindings: [],
67
+ alwaysFollow: true,
68
+ start: statementStart,
69
+ end: statementStart + statement.length,
70
+ });
71
+ }
72
+ return results;
73
+ }
18
74
  const FILE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
19
75
  function defaultIsFile(path) {
20
76
  try {
@@ -1,5 +1,13 @@
1
- import { type DynamicDetectionResult } from './detect_dynamic_usage.js';
1
+ import { type DynamicApiCheck, type DynamicDetectionResult } from './detect_dynamic_usage.js';
2
2
  import { type CollectReachableFilesIo } from './collect_reachable_files.js';
3
3
  import type { AliasConfig } from './resolve_local_imports.js';
4
4
  export type TraceDynamicUsageIo = CollectReachableFilesIo;
5
- export declare function traceDynamicUsage(entryFile: string, entrySource: string, aliases: readonly AliasConfig[], io: TraceDynamicUsageIo): DynamicDetectionResult;
5
+ export interface DynamicSignal {
6
+ api: string;
7
+ file: string;
8
+ line: number;
9
+ }
10
+ export interface TraceDynamicUsageResult extends DynamicDetectionResult {
11
+ signals: DynamicSignal[];
12
+ }
13
+ export declare function traceDynamicUsage(entryFile: string, entrySource: string, aliases: readonly AliasConfig[], io: TraceDynamicUsageIo, extraChecks?: readonly DynamicApiCheck[]): TraceDynamicUsageResult;
@@ -1,17 +1,26 @@
1
1
  import { detectDynamicUsage } from './detect_dynamic_usage.js';
2
2
  import { collectReachableFiles } from './collect_reachable_files.js';
3
- export function traceDynamicUsage(entryFile, entrySource, aliases, io) {
3
+ export function traceDynamicUsage(entryFile, entrySource, aliases, io, extraChecks = []) {
4
4
  const files = collectReachableFiles(entryFile, entrySource, aliases, io);
5
5
  let hasExplicitDynamicExport = false;
6
6
  const detectedApis = new Set();
7
+ const signals = [];
7
8
  let first = true;
8
- for (const source of files.values()) {
9
- const detection = detectDynamicUsage(source);
9
+ for (const [file, source] of files.entries()) {
10
+ const detection = detectDynamicUsage(source, extraChecks);
10
11
  if (first) {
11
12
  hasExplicitDynamicExport = detection.hasExplicitDynamicExport;
12
13
  first = false;
13
14
  }
14
- detection.detectedDynamicApis.forEach((api) => detectedApis.add(api));
15
+ detection.matches.forEach(({ name, line }) => {
16
+ detectedApis.add(name);
17
+ signals.push({ api: name, file, line });
18
+ });
15
19
  }
16
- return { hasExplicitDynamicExport, detectedDynamicApis: [...detectedApis] };
20
+ return {
21
+ hasExplicitDynamicExport,
22
+ detectedDynamicApis: [...detectedApis],
23
+ matches: signals.map(({ api, line }) => ({ name: api, line })),
24
+ signals,
25
+ };
17
26
  }
@@ -15,7 +15,7 @@ export const DEFAULT_OPTIONS = {
15
15
  formats: ["webp"],
16
16
  manifest: "public/generated/images.json",
17
17
  blur: DEFAULT_BLUR_OPTIONS,
18
- dev: true,
18
+ dev: false,
19
19
  cacheDir: "node_modules/.cache/cloudflare-next-intl/image-optimizer",
20
20
  onlyUsed: true,
21
21
  overrides: {},
@@ -35,6 +35,22 @@ function CustomLinkFunction({ href, prefetch, prefetchType = 'custom', hoverPref
35
35
  }
36
36
  window.dispatchEvent(new CustomEvent(PENDING_NAVIGATION_EVENT, { detail: null }));
37
37
  }, [pathname]);
38
+ const openedPendingEvent = useRef(false);
39
+ const wasPending = useRef(false);
40
+ useEffect(() => {
41
+ if (isPending) {
42
+ wasPending.current = true;
43
+ return;
44
+ }
45
+ if (!wasPending.current)
46
+ return;
47
+ wasPending.current = false;
48
+ setIsNavigating(false);
49
+ if (!openedPendingEvent.current)
50
+ return;
51
+ openedPendingEvent.current = false;
52
+ window.dispatchEvent(new CustomEvent(PENDING_NAVIGATION_EVENT, { detail: null }));
53
+ }, [isPending]);
38
54
  useEffect(() => {
39
55
  if (!isNavigating)
40
56
  return;
@@ -99,8 +115,10 @@ function CustomLinkFunction({ href, prefetch, prefetchType = 'custom', hoverPref
99
115
  }
100
116
  if (isCustom) {
101
117
  const targetPath = typeof pathnames === 'string' ? pathnames : urlString;
102
- if (pathname !== targetPath) {
118
+ const targetPathname = targetPath.replace(/[?#].*$/, '');
119
+ if (pathname !== targetPathname) {
103
120
  setIsNavigating(true);
121
+ openedPendingEvent.current = true;
104
122
  window.dispatchEvent(new CustomEvent(PENDING_NAVIGATION_EVENT, { detail: targetPath }));
105
123
  }
106
124
  startTransition(() => {
@@ -1,9 +1,14 @@
1
1
  import type { Plugin } from "vite";
2
- import { type DynamicPagesCheckMode } from "../dynamic_pages_check/index.js";
2
+ import { type DynamicApiCheck, type DynamicPagesCheckMode, type PageLabelStyle } from "../dynamic_pages_check/index.js";
3
3
  export interface AutoDynamicPagesPluginOptions {
4
4
  appDir?: string;
5
5
  mode?: DynamicPagesCheckMode;
6
6
  target?: 'next' | 'vinext';
7
7
  syncErrorReportingAuthUser?: boolean;
8
+ extraChecks?: readonly DynamicApiCheck[];
9
+ verbose?: boolean | {
10
+ pageLabel?: PageLabelStyle | ((file: string, appDir: string) => string);
11
+ };
12
+ restoreAfterBuild?: boolean;
8
13
  }
9
14
  export declare function autoDynamicPagesPlugin(options?: AutoDynamicPagesPluginOptions): Plugin;
@@ -1,6 +1,7 @@
1
- import { existsSync } from "node:fs";
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
+ const RESTORABLE_ACTIONS = new Set(['added-force-dynamic', 'added-force-static']);
4
5
  export function autoDynamicPagesPlugin(options = {}) {
5
6
  let ran = false;
6
7
  return {
@@ -25,13 +26,41 @@ export function autoDynamicPagesPlugin(options = {}) {
25
26
  if (!appDir || !existsSync(appDir)) {
26
27
  return;
27
28
  }
29
+ const restoreAfterBuild = options.restoreAfterBuild ?? true;
30
+ const originals = new Map();
28
31
  try {
29
- await checkDynamicPages({
32
+ const reports = await checkDynamicPages({
30
33
  appDir,
31
34
  mode: options.mode ?? "fix",
32
35
  target: options.target ?? "vinext",
33
36
  syncErrorReportingAuthUser: options.syncErrorReportingAuthUser ?? false,
34
- });
37
+ extraChecks: options.extraChecks ?? [],
38
+ verbose: options.verbose ?? false,
39
+ }, restoreAfterBuild
40
+ ? {
41
+ writeFile: (file, contents) => {
42
+ if (!originals.has(file)) {
43
+ try {
44
+ originals.set(file, readFileSync(file, "utf8"));
45
+ }
46
+ catch {
47
+ }
48
+ }
49
+ writeFileSync(file, contents, "utf8");
50
+ },
51
+ }
52
+ : undefined);
53
+ if (!restoreAfterBuild)
54
+ return;
55
+ const restorable = new Set(reports
56
+ .filter((report) => RESTORABLE_ACTIONS.has(String(report.action)))
57
+ .map((report) => report.file));
58
+ for (const file of [...originals.keys()]) {
59
+ if (!restorable.has(file))
60
+ originals.delete(file);
61
+ }
62
+ if (originals.size > 0)
63
+ registerRestore(originals);
35
64
  }
36
65
  catch (err) {
37
66
  console.warn("[cloudflare-next-intl] autoDynamicPages check error:", err);
@@ -39,3 +68,25 @@ export function autoDynamicPagesPlugin(options = {}) {
39
68
  },
40
69
  };
41
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.9.41",
3
+ "version": "0.9.44",
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",