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 +2 -1
- package/dist/src/dynamic_pages_check/check_dynamic_pages.d.ts +9 -0
- package/dist/src/dynamic_pages_check/check_dynamic_pages.js +79 -7
- package/dist/src/dynamic_pages_check/collect_reachable_files.js +21 -2
- package/dist/src/dynamic_pages_check/derive_page_label.d.ts +5 -0
- package/dist/src/dynamic_pages_check/derive_page_label.js +57 -0
- package/dist/src/dynamic_pages_check/detect_dynamic_usage.d.ts +12 -1
- package/dist/src/dynamic_pages_check/detect_dynamic_usage.js +49 -8
- package/dist/src/dynamic_pages_check/index.d.ts +4 -1
- package/dist/src/dynamic_pages_check/index.js +4 -1
- package/dist/src/dynamic_pages_check/resolve_local_imports.d.ts +8 -0
- package/dist/src/dynamic_pages_check/resolve_local_imports.js +56 -0
- package/dist/src/dynamic_pages_check/trace_dynamic_usage.d.ts +10 -2
- package/dist/src/dynamic_pages_check/trace_dynamic_usage.js +14 -5
- package/dist/src/image_optimizer/types.js +1 -1
- package/dist/src/server/components/link.js +19 -1
- package/dist/src/vite/auto_dynamic_pages_plugin.d.ts +6 -1
- package/dist/src/vite/auto_dynamic_pages_plugin.js +54 -3
- package/package.json +1 -1
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
15
|
+
detection.matches.forEach(({ name, line }) => {
|
|
16
|
+
detectedApis.add(name);
|
|
17
|
+
signals.push({ api: name, file, line });
|
|
18
|
+
});
|
|
15
19
|
}
|
|
16
|
-
return {
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|