@wular/pnext 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -0
- package/bin/pnext +67 -0
- package/config/lint/base.js +48 -0
- package/config/ts/base.json +26 -0
- package/config/ts/react.json +13 -0
- package/package.json +70 -0
- package/reference/compat.md +63 -0
- package/reference/config.md +120 -0
- package/reference/css.md +58 -0
- package/reference/dev.md +69 -0
- package/reference/env.md +40 -0
- package/reference/metadata.md +86 -0
- package/reference/navigation.md +149 -0
- package/reference/overview.md +35 -0
- package/reference/performance.md +97 -0
- package/reference/rendering.md +127 -0
- package/reference/routing.md +167 -0
- package/reference/typegen.md +64 -0
- package/src/api/cache.ts +80 -0
- package/src/api/client-cache.ts +9 -0
- package/src/api/client-navigation.ts +279 -0
- package/src/api/dynamic.tsx +102 -0
- package/src/api/link.tsx +119 -0
- package/src/api/navigation.ts +198 -0
- package/src/api/router/events.ts +53 -0
- package/src/api/router/history.ts +70 -0
- package/src/api/router/hub.ts +194 -0
- package/src/api/router/policies.ts +107 -0
- package/src/api/router/runtime.ts +5238 -0
- package/src/api/router/types.ts +299 -0
- package/src/api/router.ts +167 -0
- package/src/api/server.ts +323 -0
- package/src/api/suspense.ts +16 -0
- package/src/cache/context.ts +61 -0
- package/src/cli/adapters/vercel-warm.ts +375 -0
- package/src/cli/adapters/vercel.ts +1310 -0
- package/src/cli/analyze-print.ts +181 -0
- package/src/cli/analyze.ts +328 -0
- package/src/cli/boot-trace.ts +29 -0
- package/src/cli/build.ts +3114 -0
- package/src/cli/dev.ts +276 -0
- package/src/cli/index.ts +196 -0
- package/src/cli/named-bin.ts +119 -0
- package/src/cli/serve-ui.ts +160 -0
- package/src/cli/start.ts +1425 -0
- package/src/client/build.ts +2136 -0
- package/src/client/chunk-fold.ts +526 -0
- package/src/client/entry.ts +1525 -0
- package/src/client/paths.ts +22 -0
- package/src/client/prebuilt.ts +621 -0
- package/src/client/profile.ts +75 -0
- package/src/client/react-compiler.ts +94 -0
- package/src/client/reference-stub.ts +145 -0
- package/src/client/reference.ts +47 -0
- package/src/compat/actions/action-client.ts +531 -0
- package/src/compat/actions/action-dispatch.ts +676 -0
- package/src/compat/actions/action-router.ts +40 -0
- package/src/compat/actions/action-shared.ts +95 -0
- package/src/compat/actions/client-plugin.ts +135 -0
- package/src/compat/actions/client-stub.ts +65 -0
- package/src/compat/actions/config.ts +164 -0
- package/src/compat/actions/detect.ts +208 -0
- package/src/compat/actions/discovery.ts +244 -0
- package/src/compat/actions/early-submit.ts +40 -0
- package/src/compat/actions/endpoint.ts +602 -0
- package/src/compat/actions/flight.ts +52 -0
- package/src/compat/actions/form-state.ts +73 -0
- package/src/compat/actions/hoist.ts +485 -0
- package/src/compat/actions/ids.ts +39 -0
- package/src/compat/actions/index.ts +41 -0
- package/src/compat/actions/instances.ts +144 -0
- package/src/compat/actions/origin.ts +109 -0
- package/src/compat/actions/protocol.ts +125 -0
- package/src/compat/actions/registry.ts +74 -0
- package/src/compat/actions/rewrite.ts +282 -0
- package/src/compat/actions/serve.ts +414 -0
- package/src/compat/actions/server-tag.ts +21 -0
- package/src/compat/actions/unrecognized-error.ts +30 -0
- package/src/compat/adapter/build-complete.ts +257 -0
- package/src/compat/bundler/bun-externals.ts +53 -0
- package/src/compat/bundler/cjs-exports.ts +542 -0
- package/src/compat/bundler/config.ts +363 -0
- package/src/compat/bundler/externals.ts +34 -0
- package/src/compat/bundler/import-meta-url.ts +60 -0
- package/src/compat/bundler/modularize-imports.ts +119 -0
- package/src/compat/bundler/new-url-asset.ts +87 -0
- package/src/compat/bundler/optimize-package-imports.ts +273 -0
- package/src/compat/bundler/polyfill.ts +88 -0
- package/src/compat/bundler/react-compiler.ts +61 -0
- package/src/compat/bundler/react-profiler.tsx +25 -0
- package/src/compat/bundler/relay-transform.ts +116 -0
- package/src/compat/bundler/require-context.ts +281 -0
- package/src/compat/bundler/resolve-extensions.ts +75 -0
- package/src/compat/bundler/source-cache.ts +61 -0
- package/src/compat/bundler/static-imports.ts +25 -0
- package/src/compat/bundler/symlink-imports.ts +119 -0
- package/src/compat/bundler/tsconfig-paths.ts +50 -0
- package/src/compat/bundler/wasm.ts +153 -0
- package/src/compat/bundler/webpack-loaders.ts +685 -0
- package/src/compat/bundler/worker.ts +278 -0
- package/src/compat/cache/build-flags.ts +81 -0
- package/src/compat/cache/build-prerender-errors.ts +163 -0
- package/src/compat/cache/custom-handler.ts +159 -0
- package/src/compat/cache/fetch-patch.ts +745 -0
- package/src/compat/cache/handler.ts +100 -0
- package/src/compat/cache/modern-handler.ts +275 -0
- package/src/compat/cache/resume-data-cache.ts +143 -0
- package/src/compat/cache/revalidate.ts +759 -0
- package/src/compat/cache/runtime-error.ts +124 -0
- package/src/compat/cache/use-cache-transform.ts +961 -0
- package/src/compat/cache/use-cache.ts +1695 -0
- package/src/compat/cache-control.ts +269 -0
- package/src/compat/client/base-path.ts +64 -0
- package/src/compat/client/css-order.ts +36 -0
- package/src/compat/client/errors/control-flow.ts +92 -0
- package/src/compat/client/errors/error-boundary.ts +222 -0
- package/src/compat/client/errors/global-error.ts +238 -0
- package/src/compat/client/errors/install.ts +217 -0
- package/src/compat/client/errors/lazy.ts +53 -0
- package/src/compat/client/errors/primitive-throw.ts +126 -0
- package/src/compat/client/errors/soft-refresh.ts +14 -0
- package/src/compat/client/link-status.ts +86 -0
- package/src/compat/client/nav-compat-runtime.ts +57 -0
- package/src/compat/client/nav-compat.ts +42 -0
- package/src/compat/client/navigation-scroll.ts +154 -0
- package/src/compat/client/optimistic-routing.ts +206 -0
- package/src/compat/client/prefetch-cache.ts +111 -0
- package/src/compat/client/route-announcer.ts +72 -0
- package/src/compat/client/segment-cache-policy.ts +159 -0
- package/src/compat/client/segment-cache.ts +1077 -0
- package/src/compat/client/segment-prefetch.ts +375 -0
- package/src/compat/client/trailing-slash.ts +24 -0
- package/src/compat/css/chunking.ts +254 -0
- package/src/compat/css/inline-css.ts +73 -0
- package/src/compat/css/lightningcss.ts +90 -0
- package/src/compat/css/modules.ts +373 -0
- package/src/compat/css/nonce.ts +30 -0
- package/src/compat/css/sass-plugin.ts +65 -0
- package/src/compat/css/sass.ts +392 -0
- package/src/compat/css/styled-jsx-runtime.ts +80 -0
- package/src/compat/css/styled-jsx.ts +49 -0
- package/src/compat/edge-runtime.ts +71 -0
- package/src/compat/export/client.ts +112 -0
- package/src/compat/export/index.ts +272 -0
- package/src/compat/export/standalone.ts +207 -0
- package/src/compat/image-optimizer/cache.ts +119 -0
- package/src/compat/image-optimizer/detect.ts +143 -0
- package/src/compat/image-optimizer/index.ts +601 -0
- package/src/compat/image-optimizer/source.ts +243 -0
- package/src/compat/index.ts +458 -0
- package/src/compat/lifecycle/after-scope.ts +86 -0
- package/src/compat/lifecycle/after.ts +173 -0
- package/src/compat/lifecycle/error-funnel.ts +306 -0
- package/src/compat/lifecycle/error-serialize.ts +87 -0
- package/src/compat/lifecycle/error-ui.ts +167 -0
- package/src/compat/lifecycle/instrumentation-client.ts +138 -0
- package/src/compat/lifecycle/instrumentation.ts +277 -0
- package/src/compat/lifecycle/node-console.ts +19 -0
- package/src/compat/lifecycle/testmode.ts +263 -0
- package/src/compat/mdx/compile.ts +219 -0
- package/src/compat/mdx/next-mdx-stub.ts +46 -0
- package/src/compat/mdx/plugin.ts +37 -0
- package/src/compat/metadata-route-artifacts.ts +458 -0
- package/src/compat/metadata.ts +295 -0
- package/src/compat/middleware/manifest.ts +210 -0
- package/src/compat/misc/action-return.ts +173 -0
- package/src/compat/next/cache.ts +211 -0
- package/src/compat/next/canonical-url.ts +35 -0
- package/src/compat/next/client-cache.ts +57 -0
- package/src/compat/next/client-navigation.ts +313 -0
- package/src/compat/next/client-only.ts +3 -0
- package/src/compat/next/client-script.tsx +215 -0
- package/src/compat/next/client-server.ts +39 -0
- package/src/compat/next/config-loader.ts +569 -0
- package/src/compat/next/config.ts +29 -0
- package/src/compat/next/constants.cjs +6 -0
- package/src/compat/next/constants.ts +6 -0
- package/src/compat/next/custom-server.ts +236 -0
- package/src/compat/next/dist/client/components/app-router-headers.ts +32 -0
- package/src/compat/next/dist/server/app-render/work-unit-async-storage.external.cjs +38 -0
- package/src/compat/next/dist/server/web/spec-extension/revalidate.ts +1 -0
- package/src/compat/next/dist/server/web/spec-extension/unstable-cache.ts +1 -0
- package/src/compat/next/dist/server/web/spec-extension/unstable-no-store.ts +1 -0
- package/src/compat/next/dynamic.tsx +46 -0
- package/src/compat/next/error.tsx +148 -0
- package/src/compat/next/font/cache.ts +171 -0
- package/src/compat/next/font/google.ts +2 -0
- package/src/compat/next/font/index.ts +8 -0
- package/src/compat/next/font/local.ts +5 -0
- package/src/compat/next/font/runtime-client.ts +71 -0
- package/src/compat/next/font/runtime.ts +974 -0
- package/src/compat/next/font/shared.ts +281 -0
- package/src/compat/next/form.tsx +156 -0
- package/src/compat/next/head.tsx +10 -0
- package/src/compat/next/headers.ts +247 -0
- package/src/compat/next/image/config.ts +196 -0
- package/src/compat/next/image/optimizer.ts +96 -0
- package/src/compat/next/image/patterns.ts +103 -0
- package/src/compat/next/image/shared.ts +141 -0
- package/src/compat/next/image/static-metadata.ts +283 -0
- package/src/compat/next/image/validate.ts +269 -0
- package/src/compat/next/image-client.tsx +215 -0
- package/src/compat/next/image-props.ts +575 -0
- package/src/compat/next/image-usage.ts +102 -0
- package/src/compat/next/image.tsx +56 -0
- package/src/compat/next/index.ts +1 -0
- package/src/compat/next/legacy-image.tsx +97 -0
- package/src/compat/next/link-usage.ts +29 -0
- package/src/compat/next/link-validation-transform.ts +200 -0
- package/src/compat/next/link.tsx +466 -0
- package/src/compat/next/navigation.cjs +21 -0
- package/src/compat/next/navigation.ts +188 -0
- package/src/compat/next/offline.ts +51 -0
- package/src/compat/next/og.ts +324 -0
- package/src/compat/next/optimistic-route-state.ts +188 -0
- package/src/compat/next/preferred-region.ts +39 -0
- package/src/compat/next/redirects.ts +131 -0
- package/src/compat/next/resource-hints.ts +136 -0
- package/src/compat/next/rewrites.ts +350 -0
- package/src/compat/next/root-params.ts +142 -0
- package/src/compat/next/router.cjs +49 -0
- package/src/compat/next/router.ts +143 -0
- package/src/compat/next/script.tsx +355 -0
- package/src/compat/next/server-only.ts +3 -0
- package/src/compat/next/server.ts +28 -0
- package/src/compat/next/svgr.ts +58 -0
- package/src/compat/next/telemetry.ts +77 -0
- package/src/compat/next/user-agent.ts +100 -0
- package/src/compat/next/web-vitals.ts +56 -0
- package/src/compat/otel/api.ts +95 -0
- package/src/compat/otel/client-trace-metadata.ts +71 -0
- package/src/compat/otel/fetch-span.ts +77 -0
- package/src/compat/otel/tracer.ts +944 -0
- package/src/compat/pages/client-plugin.ts +108 -0
- package/src/compat/pages/index.ts +527 -0
- package/src/compat/pages/router-state.ts +94 -0
- package/src/compat/ppr/io.ts +38 -0
- package/src/compat/ppr/missing-root-params.ts +105 -0
- package/src/compat/ppr/root-params-scan.ts +164 -0
- package/src/compat/ppr/root-params-transform.ts +75 -0
- package/src/compat/ppr/root-params.ts +129 -0
- package/src/compat/ppr/segment-config-incompat.ts +34 -0
- package/src/compat/protocol.ts +202 -0
- package/src/compat/react/client.ts +59 -0
- package/src/compat/react/compiler-runtime.ts +60 -0
- package/src/compat/react/dom-client.ts +115 -0
- package/src/compat/react/dom-react-server.ts +20 -0
- package/src/compat/react/dom-server.ts +40 -0
- package/src/compat/react/dom.ts +154 -0
- package/src/compat/react/preact.ts +522 -0
- package/src/compat/react/react-server.ts +84 -0
- package/src/compat/react/router-shim.ts +26 -0
- package/src/compat/react/server-component-use.ts +48 -0
- package/src/compat/react/server-inserted-html.ts +87 -0
- package/src/compat/react/server.ts +156 -0
- package/src/compat/react/view-transition.ts +60 -0
- package/src/compat/register/actions.ts +875 -0
- package/src/compat/register/boot.ts +141 -0
- package/src/compat/register/build-tier.ts +11 -0
- package/src/compat/register/build.ts +182 -0
- package/src/compat/register/bundler.ts +587 -0
- package/src/compat/register/cache.ts +85 -0
- package/src/compat/register/client-errors.ts +18 -0
- package/src/compat/register/config.ts +17 -0
- package/src/compat/register/css-extras.ts +120 -0
- package/src/compat/register/edge-runtime.ts +6 -0
- package/src/compat/register/errors.ts +46 -0
- package/src/compat/register/export.ts +23 -0
- package/src/compat/register/font.ts +36 -0
- package/src/compat/register/hooks.ts +34 -0
- package/src/compat/register/image.ts +133 -0
- package/src/compat/register/index.ts +111 -0
- package/src/compat/register/instrumentation-client.ts +35 -0
- package/src/compat/register/lifecycle.ts +86 -0
- package/src/compat/register/mdx.ts +48 -0
- package/src/compat/register/middleware.ts +36 -0
- package/src/compat/register/misc.ts +44 -0
- package/src/compat/register/otel.ts +288 -0
- package/src/compat/register/pages-api.ts +473 -0
- package/src/compat/register/ppr.ts +56 -0
- package/src/compat/register/protocol.ts +57 -0
- package/src/compat/register/proxy.ts +127 -0
- package/src/compat/register/render.ts +268 -0
- package/src/compat/register/routing.ts +410 -0
- package/src/compat/register/segment.ts +1903 -0
- package/src/compat/register/static-image.ts +21 -0
- package/src/compat/register/typed-routes.ts +35 -0
- package/src/compat/register/usecache.ts +131 -0
- package/src/compat/register/validation.ts +56 -0
- package/src/compat/segment/loading-boundary.ts +113 -0
- package/src/compat/segment/page-slot.ts +200 -0
- package/src/compat/segment/tree.ts +481 -0
- package/src/compat/segment/vary-key.ts +102 -0
- package/src/compat/segment/vary-params.ts +551 -0
- package/src/compat/static-params.ts +33 -0
- package/src/compat/tsconfig-defaults.ts +301 -0
- package/src/compat/typecheck/index.ts +1481 -0
- package/src/compat/typecheck/worker.ts +26 -0
- package/src/compat/typed-routes/index.ts +92 -0
- package/src/compat/typed-routes/manifest.ts +356 -0
- package/src/compat/typed-routes/typegen.ts +566 -0
- package/src/compat/validation/errors.ts +159 -0
- package/src/compat/validation/index.ts +1770 -0
- package/src/compat/validation/prerender-diagnostics.ts +1508 -0
- package/src/compat-bootstrap.ts +67 -0
- package/src/config.ts +218 -0
- package/src/css/build.ts +697 -0
- package/src/css/index.ts +2 -0
- package/src/css/postcss.ts +236 -0
- package/src/css/worker.ts +34 -0
- package/src/dev/client-actions.ts +35 -0
- package/src/dev/client-chunk-store.ts +92 -0
- package/src/dev/client-key-cache.ts +178 -0
- package/src/dev/global-css-cache.ts +212 -0
- package/src/dev/imports.ts +2430 -0
- package/src/dev/module-cache.ts +721 -0
- package/src/dev/module-generations.ts +38 -0
- package/src/dev/module-transform.ts +188 -0
- package/src/dev/node-module-bundle-cache.ts +63 -0
- package/src/dev/restart-cache.ts +10 -0
- package/src/dev/route-bundle-key-cache.ts +154 -0
- package/src/dev/route-facts-cache.ts +223 -0
- package/src/dev/server.ts +1710 -0
- package/src/dynamic/source.ts +307 -0
- package/src/dynamic/tree-shake.ts +262 -0
- package/src/env.ts +92 -0
- package/src/extensions.ts +1898 -0
- package/src/index.ts +34 -0
- package/src/internal.ts +43 -0
- package/src/islands/boundary-error.ts +8 -0
- package/src/islands/static-children.ts +37 -0
- package/src/islands/static-slots.ts +106 -0
- package/src/ppr-postpone.ts +24 -0
- package/src/ppr.ts +784 -0
- package/src/proxy.ts +752 -0
- package/src/render/hooks.ts +384 -0
- package/src/render/index.ts +1 -0
- package/src/render/island-context.ts +47 -0
- package/src/render/metadata.ts +857 -0
- package/src/render/renderer.ts +7391 -0
- package/src/render/resource-hints.ts +44 -0
- package/src/render/slots.tsx +679 -0
- package/src/request/context.ts +396 -0
- package/src/resolve/engine.ts +219 -0
- package/src/resolve/imports.ts +1104 -0
- package/src/resolve/scan-facts.ts +474 -0
- package/src/resolve/source-text.ts +86 -0
- package/src/routing/forwarded.ts +41 -0
- package/src/routing/handler.ts +271 -0
- package/src/routing/href.ts +203 -0
- package/src/routing/metadata.ts +1018 -0
- package/src/routing/request-runtime.ts +43 -0
- package/src/routing/routes.ts +2560 -0
- package/src/routing/slots.ts +432 -0
- package/src/runtime/server.ts +3453 -0
- package/src/runtime/vendor.ts +1160 -0
- package/src/style-modules.d.ts +9 -0
- package/src/typegen.ts +151 -0
- package/src/types.ts +725 -0
- package/src/utils/ansi.ts +9 -0
- package/src/utils/content-type.ts +31 -0
- package/src/utils/decode.ts +7 -0
- package/src/utils/dev-profile.ts +31 -0
- package/src/utils/error-log.ts +29 -0
- package/src/utils/fs-cache.ts +31 -0
- package/src/utils/fs.ts +119 -0
- package/src/utils/html.ts +46 -0
- package/src/utils/serialize.ts +378 -0
- package/src/utils/source.ts +35 -0
- package/src/utils/verbose.ts +39 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,2560 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { listFiles, listFilesSync, readDirListing, toPosixPath, withDirCache } from '../utils/fs';
|
|
4
|
+
import { safeDecode } from '../utils/decode';
|
|
5
|
+
import {
|
|
6
|
+
dynamicCallsFromSource,
|
|
7
|
+
pnextDynamicImportNames,
|
|
8
|
+
rewriteLiteralDynamicCalls,
|
|
9
|
+
} from '../dynamic/source';
|
|
10
|
+
import {
|
|
11
|
+
commonJsModuleHasDefaultExport,
|
|
12
|
+
packageNameForFile,
|
|
13
|
+
packageNameOfSpecifier,
|
|
14
|
+
resolveImport,
|
|
15
|
+
resolveModuleAlias,
|
|
16
|
+
resolvePackageSpecifier,
|
|
17
|
+
} from '../resolve/imports';
|
|
18
|
+
import { scanFacts } from '../resolve/scan-facts';
|
|
19
|
+
import { readSourceSync } from '../resolve/source-text';
|
|
20
|
+
import { globalCssSourcesForPaths } from '../css';
|
|
21
|
+
import {
|
|
22
|
+
childrenDefaultFile,
|
|
23
|
+
interceptionMarkerLevels,
|
|
24
|
+
interceptionMarkerOf,
|
|
25
|
+
isGroupSegment,
|
|
26
|
+
isSlotSegment,
|
|
27
|
+
slotConventionFile,
|
|
28
|
+
slotDirectoriesIn,
|
|
29
|
+
} from './slots';
|
|
30
|
+
import type {
|
|
31
|
+
ClientEntryReason,
|
|
32
|
+
MetadataRouteEntry,
|
|
33
|
+
MetadataRouteKind,
|
|
34
|
+
NavState,
|
|
35
|
+
RouteInterception,
|
|
36
|
+
RouteManifestEntry,
|
|
37
|
+
RouteMode,
|
|
38
|
+
RouteParamValue,
|
|
39
|
+
RouteSegmentConfig,
|
|
40
|
+
} from '../types';
|
|
41
|
+
import {
|
|
42
|
+
clientReferenceId,
|
|
43
|
+
ssrClientReference,
|
|
44
|
+
type ClientDynamicReference,
|
|
45
|
+
type ClientReference,
|
|
46
|
+
} from '../client/reference';
|
|
47
|
+
import {
|
|
48
|
+
alwaysClientEntryReasons,
|
|
49
|
+
classifyRouteDependencies,
|
|
50
|
+
extraBoundaryConventionNames,
|
|
51
|
+
extraPageExtensions,
|
|
52
|
+
getBundlerExtensions,
|
|
53
|
+
getCssExtensions,
|
|
54
|
+
sourceClientEntryReasons,
|
|
55
|
+
sourceUsesRegisteredRequestApi,
|
|
56
|
+
} from '../extensions';
|
|
57
|
+
|
|
58
|
+
// Single source of truth for the page/convention extension list. `.tsx`/`.ts` stay first so an all-.tsx app
|
|
59
|
+
// resolves on the first candidate and produces the identical file as before; .js/.jsx/.mjs are additive
|
|
60
|
+
// alternatives, and compat appends `mdx`/`md` via registerPageExtensions. EVERYTHING below derives from
|
|
61
|
+
// pageExtensions()/routeHandlerExtensions() LAZILY, computed on first use and memoized, so the compat
|
|
62
|
+
// registration that runs at bootstrap - before scanRoutes - is honored; a module-level const regex is baked
|
|
63
|
+
// at import time, which is too early.
|
|
64
|
+
const BASE_PAGE_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js', 'mjs'] as const;
|
|
65
|
+
const BASE_ROUTE_HANDLER_EXTENSIONS = ['ts', 'tsx', 'js', 'mjs'] as const;
|
|
66
|
+
|
|
67
|
+
/** Base + compat-registered page extensions, de-duplicated in order. */
|
|
68
|
+
function pageExtensions(): string[] {
|
|
69
|
+
return [...new Set([...BASE_PAGE_EXTENSIONS, ...extraPageExtensions()])];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Route-handler extensions (route.*); compat page extensions do not apply. */
|
|
73
|
+
function routeHandlerExtensions(): readonly string[] {
|
|
74
|
+
return BASE_ROUTE_HANDLER_EXTENSIONS;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Union of page + route-handler extensions (metadata + trailing-file regexes). */
|
|
78
|
+
function allFileExtensions(): string[] {
|
|
79
|
+
return [...new Set([...pageExtensions(), ...routeHandlerExtensions()])];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const componentConventions = ['loading', 'error', 'not-found'] as const;
|
|
83
|
+
// Extra boundary conventions (e.g. Next's authInterrupts forbidden/unauthorized)
|
|
84
|
+
// are registered by compat via the routeConventions seam and merged in here.
|
|
85
|
+
function boundaryConventions(): string[] {
|
|
86
|
+
return [...componentConventions, ...extraBoundaryConventionNames()];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const globalErrorBase = 'global-error';
|
|
90
|
+
|
|
91
|
+
// The extension list is only known after compat bootstrap runs (which may
|
|
92
|
+
// registerPageExtensions), and scanRoutes always runs after bootstrap. Each
|
|
93
|
+
// pattern is computed on first use and memoized so the compat additions land
|
|
94
|
+
// while repeat calls stay cheap. Kept as accessors (not module-level consts)
|
|
95
|
+
// precisely to defer baking past bootstrap.
|
|
96
|
+
let memoPageFile: RegExp | undefined;
|
|
97
|
+
let memoRouteFilePattern: RegExp | undefined;
|
|
98
|
+
let memoDefaultConventionPattern: RegExp | undefined;
|
|
99
|
+
let memoMetadataCodeFilePattern: RegExp | undefined;
|
|
100
|
+
let memoPageOrRouteTrailing: RegExp | undefined;
|
|
101
|
+
let memoSlotConventionPattern: RegExp | undefined;
|
|
102
|
+
|
|
103
|
+
// Memos must not outlive a later registerPageExtensions call: Pages-compat
|
|
104
|
+
// materialization scans routes during config resolution, BEFORE
|
|
105
|
+
// registerMdxExtensions runs, and a pattern baked then would lock `.mdx` out.
|
|
106
|
+
let memoExtensionsKey: string | undefined;
|
|
107
|
+
function freshMemos(): void {
|
|
108
|
+
const key = extraPageExtensions().join(',');
|
|
109
|
+
if (key === memoExtensionsKey) return;
|
|
110
|
+
memoExtensionsKey = key;
|
|
111
|
+
memoPageFile = undefined;
|
|
112
|
+
memoRouteFilePattern = undefined;
|
|
113
|
+
memoDefaultConventionPattern = undefined;
|
|
114
|
+
memoMetadataCodeFilePattern = undefined;
|
|
115
|
+
memoPageOrRouteTrailing = undefined;
|
|
116
|
+
memoSlotConventionPattern = undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pageFile(): RegExp {
|
|
120
|
+
freshMemos();
|
|
121
|
+
return (memoPageFile ??= extensionRegex('page', pageExtensions()));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function routeFilePattern(): RegExp {
|
|
125
|
+
freshMemos();
|
|
126
|
+
return (memoRouteFilePattern ??= extensionRegex('route', routeHandlerExtensions()));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function defaultConventionPattern(): RegExp {
|
|
130
|
+
freshMemos();
|
|
131
|
+
return (memoDefaultConventionPattern ??= extensionRegex('default', pageExtensions()));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function metadataCodeFilePattern(): RegExp {
|
|
135
|
+
freshMemos();
|
|
136
|
+
return (memoMetadataCodeFilePattern ??= new RegExp(
|
|
137
|
+
`(^|/)(robots|sitemap|manifest|icon\\d*|apple-icon\\d*|opengraph-image\\d*|twitter-image\\d*)\\.(${allFileExtensions().join(
|
|
138
|
+
'|',
|
|
139
|
+
)})$`,
|
|
140
|
+
));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function pageOrRouteTrailing(): RegExp {
|
|
144
|
+
freshMemos();
|
|
145
|
+
return (memoPageOrRouteTrailing ??= new RegExp(
|
|
146
|
+
`/?(page|route|layout|template|loading|error|not-found|forbidden|unauthorized|default|global-error)\\.(${allFileExtensions().join(
|
|
147
|
+
'|',
|
|
148
|
+
)})$`,
|
|
149
|
+
));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function slotConventionPattern(): RegExp {
|
|
153
|
+
freshMemos();
|
|
154
|
+
return (memoSlotConventionPattern ??= new RegExp(
|
|
155
|
+
`(^|/)(page|layout|template|loading|error|default|not-found)\\.(${pageExtensions().join('|')})$`,
|
|
156
|
+
));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function extensionRegex(base: string, extensions: readonly string[]) {
|
|
160
|
+
return new RegExp(`(^|/)${escapeRegex(base)}\\.(${extensions.join('|')})$`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Nearest existing file named `<base>.<ext>` for the convention extensions. */
|
|
164
|
+
function conventionFileName(dir: string, base: string) {
|
|
165
|
+
const { files } = readDirListing(dir);
|
|
166
|
+
for (const extension of pageExtensions()) {
|
|
167
|
+
if (files.has(`${base}.${extension}`)) return path.join(dir, `${base}.${extension}`);
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface RouteParts {
|
|
173
|
+
route: string;
|
|
174
|
+
pattern: string;
|
|
175
|
+
params: string[];
|
|
176
|
+
catchAll?: string;
|
|
177
|
+
catchAllOptional?: boolean;
|
|
178
|
+
interception?: Omit<RouteInterception, 'slotDir'>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface ModuleEdge {
|
|
182
|
+
file: string;
|
|
183
|
+
exports: string[];
|
|
184
|
+
dynamic?: ClientDynamicReference;
|
|
185
|
+
/** `export * from` the target: its named exports are re-exported by this module. */
|
|
186
|
+
star?: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface ScanContext {
|
|
190
|
+
root: string;
|
|
191
|
+
appPath: string;
|
|
192
|
+
sources: Map<string, string>;
|
|
193
|
+
edges: Map<string, ModuleEdge[]>;
|
|
194
|
+
requestImportUsage: Map<string, boolean>;
|
|
195
|
+
/**
|
|
196
|
+
* Per-FILE facts, path-keyed. Every route re-walks its whole source closure and each visit
|
|
197
|
+
* re-derived these from the text. Same lifetime and staleness rules as `sources`.
|
|
198
|
+
*/
|
|
199
|
+
exists: Map<string, boolean>;
|
|
200
|
+
useClient: Map<string, boolean>;
|
|
201
|
+
usesLink: Map<string, boolean>;
|
|
202
|
+
usesNavigation: Map<string, boolean>;
|
|
203
|
+
entryReasons: Map<string, ClientEntryReason[]>;
|
|
204
|
+
/** Root-layout CSS closure — itself a content walk, so it resolves with the first CSS fact. */
|
|
205
|
+
globalCssImports(): Set<string>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function createScanContext(appPath: string, globalCss?: () => Set<string>): ScanContext {
|
|
209
|
+
const root = rootFromAppPath(appPath);
|
|
210
|
+
let memo: Set<string> | undefined;
|
|
211
|
+
return {
|
|
212
|
+
root,
|
|
213
|
+
appPath,
|
|
214
|
+
sources: new Map(),
|
|
215
|
+
edges: new Map(),
|
|
216
|
+
requestImportUsage: new Map(),
|
|
217
|
+
exists: new Map(),
|
|
218
|
+
useClient: new Map(),
|
|
219
|
+
usesLink: new Map(),
|
|
220
|
+
usesNavigation: new Map(),
|
|
221
|
+
entryReasons: new Map(),
|
|
222
|
+
globalCssImports:
|
|
223
|
+
globalCss ?? (() => (memo ??= new Set(globalCssSourcesForPaths(root, appPath)))),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `existsSync` once per path per scan — the closure walk asks the same paths per route. */
|
|
228
|
+
function fileExists(context: ScanContext, file: string) {
|
|
229
|
+
let hit = context.exists.get(file);
|
|
230
|
+
if (hit === undefined) {
|
|
231
|
+
hit = existsSync(file);
|
|
232
|
+
context.exists.set(file, hit);
|
|
233
|
+
}
|
|
234
|
+
return hit;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const routeHandlerMethods = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as const;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The entry fields that need file CONTENT. Boot never reads them: the route table is built from path
|
|
241
|
+
* conventions alone, which fully determine matching, and these resolve on first access - the route's first
|
|
242
|
+
* compile - through one memoized scan-facts pass per file, which that compile then re-uses.
|
|
243
|
+
*/
|
|
244
|
+
export interface RouteFacts {
|
|
245
|
+
mode: RouteMode;
|
|
246
|
+
hasStaticParams: boolean;
|
|
247
|
+
usesRequest: boolean;
|
|
248
|
+
handlerUsesRevalidationApi?: true;
|
|
249
|
+
dynamicErrorApi?: string;
|
|
250
|
+
client: boolean;
|
|
251
|
+
clientReferences: ClientReference[];
|
|
252
|
+
cssImports: string[];
|
|
253
|
+
sourceFiles: string[];
|
|
254
|
+
stream?: RouteManifestEntry['stream'];
|
|
255
|
+
maxDuration?: number;
|
|
256
|
+
ppr?: boolean;
|
|
257
|
+
segmentConfig?: RouteSegmentConfig;
|
|
258
|
+
needsRouterEntry?: true;
|
|
259
|
+
clientEntryReasons?: ClientEntryReason[];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const deferredFields = [
|
|
263
|
+
'mode',
|
|
264
|
+
'hasStaticParams',
|
|
265
|
+
'usesRequest',
|
|
266
|
+
'handlerUsesRevalidationApi',
|
|
267
|
+
'dynamicErrorApi',
|
|
268
|
+
'client',
|
|
269
|
+
'clientReferences',
|
|
270
|
+
'cssImports',
|
|
271
|
+
'sourceFiles',
|
|
272
|
+
'stream',
|
|
273
|
+
'maxDuration',
|
|
274
|
+
'ppr',
|
|
275
|
+
'segmentConfig',
|
|
276
|
+
'needsRouterEntry',
|
|
277
|
+
'clientEntryReasons',
|
|
278
|
+
] as const satisfies readonly (keyof RouteFacts)[];
|
|
279
|
+
|
|
280
|
+
/** Path-only entry fields; everything in RouteFacts is layered on lazily. */
|
|
281
|
+
type RoutePathEntry = Omit<RouteManifestEntry, keyof RouteFacts>;
|
|
282
|
+
|
|
283
|
+
const resolvedRoutes = new WeakSet<RouteManifestEntry>();
|
|
284
|
+
let factsVersion = 0;
|
|
285
|
+
|
|
286
|
+
/** Bumped each time a route resolves its deferred facts (dev re-keys watch roots off it). */
|
|
287
|
+
export function routeFactsVersion() {
|
|
288
|
+
return factsVersion;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function routeFactsResolved(route: RouteManifestEntry) {
|
|
292
|
+
return resolvedRoutes.has(route);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Force every route's deferred facts - the build and typegen need the whole table. `onRoute` fires as each
|
|
297
|
+
* route's facts land, so a consumer that only needs one route's file list (the client stage) can start on it
|
|
298
|
+
* instead of waiting for the last route to scan.
|
|
299
|
+
*/
|
|
300
|
+
export function materializeRouteFacts<T extends RouteManifestEntry[]>(
|
|
301
|
+
routes: T,
|
|
302
|
+
onRoute?: (route: RouteManifestEntry) => void,
|
|
303
|
+
): T {
|
|
304
|
+
for (const route of routes) {
|
|
305
|
+
void route.sourceFiles;
|
|
306
|
+
onRoute?.(route);
|
|
307
|
+
}
|
|
308
|
+
return routes;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The same walk for a consumer that must not hold the event loop: one route per turn, with `pause`
|
|
313
|
+
* awaited in between. A build wants the loop (nothing else runs); a dev server does not, and a first
|
|
314
|
+
* page landing mid-walk would otherwise wait out all of it before its own first `await` resumes.
|
|
315
|
+
*/
|
|
316
|
+
export async function materializeRouteFactsPaced<T extends RouteManifestEntry[]>(
|
|
317
|
+
routes: T,
|
|
318
|
+
pause: () => Promise<void>,
|
|
319
|
+
): Promise<T> {
|
|
320
|
+
for (const route of routes) {
|
|
321
|
+
await pause();
|
|
322
|
+
void route.sourceFiles;
|
|
323
|
+
}
|
|
324
|
+
return routes;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A store that survives the process - the dev server installs one so a restart reads the walk's result
|
|
329
|
+
* instead of re-running it. `load` returns the recorded facts only when every source they were derived from
|
|
330
|
+
* still has the content it was recorded with; `save` is called once, with the facts a fresh walk produced.
|
|
331
|
+
*/
|
|
332
|
+
export interface RouteFactsStore {
|
|
333
|
+
load(routeId: string): RouteFacts | undefined;
|
|
334
|
+
save(routeId: string, facts: RouteFacts): void;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let factsStore: RouteFactsStore | undefined;
|
|
338
|
+
|
|
339
|
+
export function setRouteFactsStore(store: RouteFactsStore | undefined) {
|
|
340
|
+
factsStore = store;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function withDeferredFacts(base: RoutePathEntry, compute: () => RouteFacts): RouteManifestEntry {
|
|
344
|
+
const entry = base as RouteManifestEntry;
|
|
345
|
+
let facts: RouteFacts | undefined;
|
|
346
|
+
// Consumers still assign some of these (the build marks needsRouterEntry), so
|
|
347
|
+
// each field keeps a setter that wins over the computed value without forcing
|
|
348
|
+
// the scan.
|
|
349
|
+
const overrides = new Map<string, unknown>();
|
|
350
|
+
|
|
351
|
+
for (const field of deferredFields) {
|
|
352
|
+
Object.defineProperty(entry, field, {
|
|
353
|
+
enumerable: true,
|
|
354
|
+
configurable: true,
|
|
355
|
+
get() {
|
|
356
|
+
if (overrides.has(field)) return overrides.get(field);
|
|
357
|
+
if (!facts) {
|
|
358
|
+
const key = `${base.kind}:${base.id}`;
|
|
359
|
+
const stored = factsStore?.load(key);
|
|
360
|
+
facts = stored ?? withDirCache(compute);
|
|
361
|
+
if (!stored) factsStore?.save(key, facts);
|
|
362
|
+
resolvedRoutes.add(entry);
|
|
363
|
+
factsVersion += 1;
|
|
364
|
+
}
|
|
365
|
+
return facts[field];
|
|
366
|
+
},
|
|
367
|
+
set(value: unknown) {
|
|
368
|
+
overrides.set(field, value);
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return entry;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function scanRoutes(appPath: string): Promise<RouteManifestEntry[]> {
|
|
377
|
+
const files = await listFiles(appPath);
|
|
378
|
+
return withDirCache(() => buildRouteTable(appPath, files));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The path pass: file paths in, route table out — no file content is read. */
|
|
382
|
+
function buildRouteTable(appPath: string, files: string[]): RouteManifestEntry[] {
|
|
383
|
+
const context = createScanContext(appPath);
|
|
384
|
+
const routes: RouteManifestEntry[] = [];
|
|
385
|
+
const slotPageFiles: { file: string; relative: string }[] = [];
|
|
386
|
+
const defaultPageFiles: { file: string; relative: string }[] = [];
|
|
387
|
+
// Parallel/intercepting-route semantics only exist on client navigations, so
|
|
388
|
+
// when the app uses them every page ships the router runtime. Both facts are
|
|
389
|
+
// path-shaped, but the whole table must be walked before they are known —
|
|
390
|
+
// hence a holder the per-route needsRouterEntry closures read later.
|
|
391
|
+
const table = { parallelRoutes: false };
|
|
392
|
+
|
|
393
|
+
for (const file of files) {
|
|
394
|
+
const relative = toPosixPath(path.relative(appPath, file));
|
|
395
|
+
const routeFile = path.join(appPath, relative);
|
|
396
|
+
if (
|
|
397
|
+
defaultConventionPattern().test(relative) &&
|
|
398
|
+
!relative.split('/').some(segment => segment.startsWith('@'))
|
|
399
|
+
) {
|
|
400
|
+
defaultPageFiles.push({ file, relative });
|
|
401
|
+
}
|
|
402
|
+
// App segments beginning with `_` are private (Next ignores them); the
|
|
403
|
+
// route is dropped so requesting it 404s. A directory literally named
|
|
404
|
+
// `%5Ffoo` decodes to a routable `/_foo` (handled in routeParts), so it is
|
|
405
|
+
// NOT private. Applies to pages, handlers, and metadata routes alike.
|
|
406
|
+
if (isPrivateAppPath(relative)) continue;
|
|
407
|
+
if (!pageFile().test(relative) && !routeFilePattern().test(relative)) {
|
|
408
|
+
if (!metadataCodeFilePattern().test(relative)) continue;
|
|
409
|
+
// The one content read boot cannot defer: a metadata file's generateSitemaps/
|
|
410
|
+
// generateImageMetadata export decides whether its URL carries an id segment,
|
|
411
|
+
// so it shapes the PATTERN. Bounded to metadata files (a handful per app) and
|
|
412
|
+
// to their own source — no module-graph walk.
|
|
413
|
+
const source = readSource(context, file);
|
|
414
|
+
const metadataParts = dynamicMetadataRouteParts(relative, source);
|
|
415
|
+
if (!metadataParts) continue;
|
|
416
|
+
routes.push(
|
|
417
|
+
withDeferredFacts(
|
|
418
|
+
{
|
|
419
|
+
id: `metadata-${routeId(metadataParts.route)}`,
|
|
420
|
+
kind: 'handler',
|
|
421
|
+
route: metadataParts.route,
|
|
422
|
+
pattern: metadataParts.pattern,
|
|
423
|
+
file,
|
|
424
|
+
params: metadataParts.params,
|
|
425
|
+
catchAll: metadataParts.catchAll,
|
|
426
|
+
...(metadataParts.catchAllOptional ? { catchAllOptional: true } : {}),
|
|
427
|
+
metadataRoute: metadataParts.metadataRoute,
|
|
428
|
+
},
|
|
429
|
+
() => metadataRouteFacts(context, routeFile, metadataParts),
|
|
430
|
+
),
|
|
431
|
+
);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
// Pages inside `@slot` dirs primarily render as slot content of their
|
|
435
|
+
// owning segment; they also derive standalone entries (synthetic slot URLs
|
|
436
|
+
// and slot interception targets) in a second pass below.
|
|
437
|
+
if (relative.split('/').some(segment => segment.startsWith('@'))) {
|
|
438
|
+
if (pageFile().test(relative)) slotPageFiles.push({ file, relative });
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const kind = pageFile().test(relative) ? 'page' : 'handler';
|
|
443
|
+
const routeDir = relative.replace(pageOrRouteTrailing(), '');
|
|
444
|
+
const parts = routeParts(routeDir);
|
|
445
|
+
const conventions = conventionPaths(appPath, routeFile, kind);
|
|
446
|
+
const templatePaths = conventions.templateFiles.filter(item => existsSync(item));
|
|
447
|
+
// authInterrupt boundary files (forbidden/unauthorized) are only discovered
|
|
448
|
+
// when compat registered those conventions via the routeConventions seam.
|
|
449
|
+
const extraBoundaries = new Set(extraBoundaryConventionNames());
|
|
450
|
+
const forbiddenPaths =
|
|
451
|
+
kind === 'page' && extraBoundaries.has('forbidden')
|
|
452
|
+
? findConventionFiles(appPath, routeFile, 'forbidden').filter(item => existsSync(item))
|
|
453
|
+
: [];
|
|
454
|
+
const unauthorizedPaths =
|
|
455
|
+
kind === 'page' && extraBoundaries.has('unauthorized')
|
|
456
|
+
? findConventionFiles(appPath, routeFile, 'unauthorized').filter(item => existsSync(item))
|
|
457
|
+
: [];
|
|
458
|
+
|
|
459
|
+
// An interception entry's route/pattern is the TARGET path it responds
|
|
460
|
+
// to; it only matches soft navigations (see matchInterception), renders
|
|
461
|
+
// per request, and never prerenders.
|
|
462
|
+
const interception = parts.interception;
|
|
463
|
+
routes.push(
|
|
464
|
+
withDeferredFacts(
|
|
465
|
+
{
|
|
466
|
+
id: interception ? `intercept-${sanitizeIdPart(routeDir)}` : routeId(parts.route || '/'),
|
|
467
|
+
kind,
|
|
468
|
+
route: parts.route,
|
|
469
|
+
pattern: parts.pattern,
|
|
470
|
+
file,
|
|
471
|
+
params: parts.params,
|
|
472
|
+
catchAll: parts.catchAll,
|
|
473
|
+
...(parts.catchAllOptional ? { catchAllOptional: true } : {}),
|
|
474
|
+
...(templatePaths.length > 0 ? { templateFiles: templatePaths } : {}),
|
|
475
|
+
...(forbiddenPaths.length > 0 ? { forbiddenFiles: forbiddenPaths } : {}),
|
|
476
|
+
...(unauthorizedPaths.length > 0 ? { unauthorizedFiles: unauthorizedPaths } : {}),
|
|
477
|
+
...(conventions.slotDirs.length > 0 ? { slotDirs: conventions.slotDirs } : {}),
|
|
478
|
+
...(interception ? { interception } : {}),
|
|
479
|
+
},
|
|
480
|
+
() => pageOrHandlerFacts(context, { kind, routeFile, parts, conventions, table }),
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
appendSlotDerivedRoutes(context, routes, slotPageFiles, table);
|
|
486
|
+
appendDefaultDerivedRoutes(context, routes, defaultPageFiles, table);
|
|
487
|
+
table.parallelRoutes = routes.some(route => route.slotDirs?.length || route.interception);
|
|
488
|
+
|
|
489
|
+
return routes.sort((a, b) => routeRank(a).localeCompare(routeRank(b)));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Convention files reachable from a route file — all decided by paths on disk. */
|
|
493
|
+
function conventionPaths(appPath: string, routeFile: string, kind: 'page' | 'handler') {
|
|
494
|
+
if (kind !== 'page') {
|
|
495
|
+
return { layoutFiles: [], templateFiles: [], specialFiles: [], slotDirs: [], slotFiles: [] };
|
|
496
|
+
}
|
|
497
|
+
const slotDirs = chainSlotDirs(appPath, routeFile);
|
|
498
|
+
return {
|
|
499
|
+
layoutFiles: findLayouts(appPath, routeFile),
|
|
500
|
+
templateFiles: findConventionFiles(appPath, routeFile, 'template'),
|
|
501
|
+
specialFiles: boundaryConventions().flatMap(name =>
|
|
502
|
+
findConventionFiles(appPath, routeFile, name),
|
|
503
|
+
),
|
|
504
|
+
slotDirs,
|
|
505
|
+
slotFiles: slotTreeFiles(slotDirs),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
type ConventionPaths = ReturnType<typeof conventionPaths>;
|
|
510
|
+
|
|
511
|
+
/** Set once the whole table is known; the closures below read it, never at boot. */
|
|
512
|
+
interface TableFlags {
|
|
513
|
+
parallelRoutes: boolean;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function metadataRouteFacts(
|
|
517
|
+
context: ScanContext,
|
|
518
|
+
routeFile: string,
|
|
519
|
+
parts: DynamicMetadataRouteParts,
|
|
520
|
+
): RouteFacts {
|
|
521
|
+
const source = readSource(context, routeFile);
|
|
522
|
+
const sourceFiles = collectSourceFiles(context, [routeFile]);
|
|
523
|
+
const dependency = routeDependencyClassification(context, 'handler', sourceFiles);
|
|
524
|
+
const usesRequest =
|
|
525
|
+
metadataRouteUsesRequest(source) ||
|
|
526
|
+
usesRevalidationApis(source) ||
|
|
527
|
+
dependency.usesRequest === true ||
|
|
528
|
+
usesRequestImportDataForFiles(context, sourceFiles.filter(file => !isAssetFile(file)));
|
|
529
|
+
const segmentConfig = segmentConfigFromEntries(
|
|
530
|
+
context.appPath,
|
|
531
|
+
[{ file: routeFile, source }],
|
|
532
|
+
parts,
|
|
533
|
+
);
|
|
534
|
+
return {
|
|
535
|
+
mode: inferRouteMode(parts, usesRequest, segmentConfig),
|
|
536
|
+
hasStaticParams: Boolean(parts.metadataRoute.generatedParam),
|
|
537
|
+
usesRequest,
|
|
538
|
+
client: false,
|
|
539
|
+
clientReferences: [],
|
|
540
|
+
cssImports: [],
|
|
541
|
+
sourceFiles,
|
|
542
|
+
maxDuration: maxDurationFromSources([source]),
|
|
543
|
+
...(segmentConfig ? { segmentConfig } : {}),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function pageOrHandlerFacts(
|
|
548
|
+
context: ScanContext,
|
|
549
|
+
options: {
|
|
550
|
+
kind: 'page' | 'handler';
|
|
551
|
+
routeFile: string;
|
|
552
|
+
parts: RouteParts;
|
|
553
|
+
conventions: ConventionPaths;
|
|
554
|
+
table: TableFlags;
|
|
555
|
+
},
|
|
556
|
+
): RouteFacts {
|
|
557
|
+
const { appPath } = context;
|
|
558
|
+
const { kind, routeFile, parts, conventions, table } = options;
|
|
559
|
+
const { layoutFiles, templateFiles, specialFiles, slotFiles } = conventions;
|
|
560
|
+
const source = readScanSource(context, routeFile);
|
|
561
|
+
const layoutSources = [...layoutFiles, ...templateFiles, ...specialFiles]
|
|
562
|
+
.filter(item => existsSync(item))
|
|
563
|
+
.map(item => readScanSource(context, item));
|
|
564
|
+
const entryFiles = [routeFile, ...layoutFiles, ...templateFiles, ...specialFiles, ...slotFiles];
|
|
565
|
+
const sourceFiles = collectSourceFiles(context, entryFiles);
|
|
566
|
+
const dependency = routeDependencyClassification(context, kind, sourceFiles);
|
|
567
|
+
const handlerUsesRevalidationApi = kind === 'handler' && usesRevalidationApis(source);
|
|
568
|
+
const usesRequest =
|
|
569
|
+
kind === 'handler'
|
|
570
|
+
? routeHandlerUsesRequest(source) || handlerUsesRevalidationApi || dependency.usesRequest === true
|
|
571
|
+
: [source, ...layoutSources].some(usesRequestData) || dependency.usesRequest === true;
|
|
572
|
+
// Layout sources only (leaf-first, page first): segment config exports in
|
|
573
|
+
// templates/special files have no route-level meaning.
|
|
574
|
+
const existingLayoutFiles = layoutFiles.filter(item => existsSync(item));
|
|
575
|
+
const configEntries = [
|
|
576
|
+
{ file: routeFile, source },
|
|
577
|
+
...[...existingLayoutFiles].reverse().map(item => ({
|
|
578
|
+
file: item,
|
|
579
|
+
source: readScanSource(context, item),
|
|
580
|
+
})),
|
|
581
|
+
];
|
|
582
|
+
const segmentConfig = segmentConfigFromEntries(appPath, configEntries, parts);
|
|
583
|
+
if (segmentConfig?.dynamicParamsFalse?.length) {
|
|
584
|
+
// `dynamicParams = false` only 404s unlisted params when the route is fully statically enumerable -
|
|
585
|
+
// every dynamic segment must be generated by a generateStaticParams somewhere in the chain. If a dynamic
|
|
586
|
+
// segment has no static params, the route renders on demand and the ancestor's dynamicParams config no
|
|
587
|
+
// longer restricts it (Next treats the whole path as dynamic).
|
|
588
|
+
const allDynamicParams = [...parts.params, ...(parts.catchAll ? [parts.catchAll] : [])];
|
|
589
|
+
const coveredParams = new Set<string>();
|
|
590
|
+
for (const entry of configEntries) {
|
|
591
|
+
if (!hasStaticParamsExport(entry.source)) continue;
|
|
592
|
+
const covered = paramOfOwnSegment(appPath, entry.file, allDynamicParams);
|
|
593
|
+
if (covered) coveredParams.add(covered);
|
|
594
|
+
}
|
|
595
|
+
if (allDynamicParams.some(param => !coveredParams.has(param))) {
|
|
596
|
+
delete segmentConfig.dynamicParamsFalse;
|
|
597
|
+
} else if (hasStaticParamsExport(source)) {
|
|
598
|
+
segmentConfig.strictDynamicParams = true;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const hasStaticParams =
|
|
602
|
+
hasStaticParamsExport(source) ||
|
|
603
|
+
(kind === 'page' &&
|
|
604
|
+
existingLayoutFiles.map(item => readScanSource(context, item)).some(hasStaticParamsExport));
|
|
605
|
+
// `dynamic = 'error'` forbids dynamic data: a static reference to one is a
|
|
606
|
+
// build failure (enforced in the prerender pass). Restricted to routes with
|
|
607
|
+
// no dynamic params and no generateStaticParams: those prerender a single
|
|
608
|
+
// top-level render, so a referenced dynamic API is reached unconditionally.
|
|
609
|
+
// A parameterized `dynamic = 'error'` route (e.g. `[id]` guarding cookies()
|
|
610
|
+
// behind a runtime param check) builds fine in Next and only errors at
|
|
611
|
+
// request time, so it must not fail the build. Layout sources count for
|
|
612
|
+
// pages so a dynamic API read in a shared layout is still caught.
|
|
613
|
+
const dynamicErrorApi =
|
|
614
|
+
segmentConfig?.dynamic === 'error' &&
|
|
615
|
+
parts.params.length === 0 &&
|
|
616
|
+
!parts.catchAll &&
|
|
617
|
+
!hasStaticParams
|
|
618
|
+
? dynamicApiLabel(kind === 'handler' ? [source] : [source, ...layoutSources], kind)
|
|
619
|
+
: undefined;
|
|
620
|
+
const ppr =
|
|
621
|
+
kind === 'page' &&
|
|
622
|
+
usesRequest &&
|
|
623
|
+
parts.params.length === 0 &&
|
|
624
|
+
!parts.catchAll &&
|
|
625
|
+
exportsExperimentalPpr([source, ...layoutSources]);
|
|
626
|
+
|
|
627
|
+
const directClient = kind === 'page' && hasUseClientDirective(routeFile, source);
|
|
628
|
+
const clientReferences =
|
|
629
|
+
kind === 'page'
|
|
630
|
+
? collectClientReferences(
|
|
631
|
+
context,
|
|
632
|
+
directClient
|
|
633
|
+
? [...layoutFiles, ...templateFiles, ...specialFiles, ...slotFiles]
|
|
634
|
+
: entryFiles,
|
|
635
|
+
)
|
|
636
|
+
: [];
|
|
637
|
+
const client =
|
|
638
|
+
directClient ||
|
|
639
|
+
clientReferences.some(reference => reference.file === routeFile && reference.exportName === 'default');
|
|
640
|
+
const stream =
|
|
641
|
+
kind === 'page'
|
|
642
|
+
? routeStreamMetadata({ context, layoutFiles, templateFiles, specialFiles })
|
|
643
|
+
: undefined;
|
|
644
|
+
const cssImports =
|
|
645
|
+
kind === 'page'
|
|
646
|
+
? collectCssImports(
|
|
647
|
+
context,
|
|
648
|
+
cssEntryOrder({ routeFile, layoutFiles, templateFiles, specialFiles, slotFiles }),
|
|
649
|
+
{ route: true },
|
|
650
|
+
)
|
|
651
|
+
: [];
|
|
652
|
+
if (kind === 'page') attachLazyReferenceCss(context, clientReferences);
|
|
653
|
+
|
|
654
|
+
const interception = parts.interception;
|
|
655
|
+
return {
|
|
656
|
+
mode: interception ? 'dynamic' : inferRouteMode(parts, usesRequest, segmentConfig),
|
|
657
|
+
hasStaticParams: interception ? false : hasStaticParams,
|
|
658
|
+
usesRequest,
|
|
659
|
+
...(handlerUsesRevalidationApi ? { handlerUsesRevalidationApi: true as const } : {}),
|
|
660
|
+
...(dynamicErrorApi ? { dynamicErrorApi } : {}),
|
|
661
|
+
client,
|
|
662
|
+
clientReferences,
|
|
663
|
+
cssImports,
|
|
664
|
+
sourceFiles,
|
|
665
|
+
...(stream ? { stream } : {}),
|
|
666
|
+
maxDuration: maxDurationFromSources([source, ...layoutSources]),
|
|
667
|
+
ppr: interception ? false : ppr,
|
|
668
|
+
...(segmentConfig ? { segmentConfig } : {}),
|
|
669
|
+
...routerEntryFact(context, {
|
|
670
|
+
kind,
|
|
671
|
+
client,
|
|
672
|
+
clientReferences,
|
|
673
|
+
sourceFiles,
|
|
674
|
+
// Only slot-derived interception entries carry a slotDir.
|
|
675
|
+
slotInterceptor: false,
|
|
676
|
+
table,
|
|
677
|
+
}),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Why this page needs the client router entry, as a reason list rather than a yes/no - the client build gates
|
|
683
|
+
* its feature regions on the reasons, so a page that only renders <Link> must not be told "you need
|
|
684
|
+
* everything".
|
|
685
|
+
*
|
|
686
|
+
* <Link> emits interactive `<a data-pnext-link>` anchors the soft-navigation runtime drives (prefetch, soft
|
|
687
|
+
* nav, scroll/focus). Without a client component the page would otherwise ship no entry, so the router never
|
|
688
|
+
* installs and every Link falls back to a full document load.
|
|
689
|
+
*/
|
|
690
|
+
function routerEntryFact(
|
|
691
|
+
context: ScanContext,
|
|
692
|
+
options: {
|
|
693
|
+
kind: 'page' | 'handler';
|
|
694
|
+
client: boolean;
|
|
695
|
+
clientReferences: ClientReference[];
|
|
696
|
+
sourceFiles: string[];
|
|
697
|
+
slotInterceptor: boolean;
|
|
698
|
+
table: TableFlags;
|
|
699
|
+
},
|
|
700
|
+
): { needsRouterEntry?: true; clientEntryReasons?: ClientEntryReason[] } {
|
|
701
|
+
if (options.kind !== 'page') return {};
|
|
702
|
+
const reasons: ClientEntryReason[] = [];
|
|
703
|
+
// Slot interceptors never render standalone (the origin page hosts them);
|
|
704
|
+
// children interceptors render directly and need the runtime.
|
|
705
|
+
if (options.table.parallelRoutes && !options.slotInterceptor) reasons.push('parallel-routes');
|
|
706
|
+
// Compat parity: Next ships its router bootstrap on every app page, so a page
|
|
707
|
+
// with no client code of its own still hydrates (and runs
|
|
708
|
+
// instrumentation-client before hydration).
|
|
709
|
+
reasons.push(...alwaysClientEntryReasons());
|
|
710
|
+
// A page with client code of its own already ships an entry, so the <Link> scan only exists to catch the
|
|
711
|
+
// pages that otherwise would not. The remaining reasons gate feature regions INSIDE the entry (the action
|
|
712
|
+
// runtime), so they are scanned for every page - a page's inline `'use server'` action is usually handed to
|
|
713
|
+
// an island it also renders.
|
|
714
|
+
if (!options.client && options.clientReferences.length === 0) {
|
|
715
|
+
if (routeUsesLinkComponent(context, options.sourceFiles)) reasons.push('link');
|
|
716
|
+
} else if (routeUsesNavigation(context, options.sourceFiles)) {
|
|
717
|
+
// The complement of the branch above: a page that already ships an entry
|
|
718
|
+
// never needed a reason before, so nothing here changes whether the entry
|
|
719
|
+
// exists — it decides whether that entry carries the router at all.
|
|
720
|
+
reasons.push('router-api');
|
|
721
|
+
}
|
|
722
|
+
reasons.push(...routeClientEntryReasons(context, options.sourceFiles));
|
|
723
|
+
if (reasons.length === 0) return {};
|
|
724
|
+
return { needsRouterEntry: true, clientEntryReasons: [...new Set(reasons)] };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Record one more reason a route needs the client entry, from a consumer that
|
|
729
|
+
* learns it after the scan (the build discovers server actions only once action
|
|
730
|
+
* discovery has run). Keeps `needsRouterEntry` in step with the reason list.
|
|
731
|
+
*/
|
|
732
|
+
export function addClientEntryReason(route: RouteManifestEntry, reason: ClientEntryReason): void {
|
|
733
|
+
const reasons = route.clientEntryReasons ?? [];
|
|
734
|
+
if (!reasons.includes(reason)) route.clientEntryReasons = [...reasons, reason];
|
|
735
|
+
route.needsRouterEntry = true;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function routeStreamMetadata(options: {
|
|
739
|
+
context: ScanContext;
|
|
740
|
+
layoutFiles: string[];
|
|
741
|
+
templateFiles: string[];
|
|
742
|
+
specialFiles: string[];
|
|
743
|
+
}) {
|
|
744
|
+
const existingSpecialFiles = options.specialFiles.filter(item => existsSync(item));
|
|
745
|
+
const hasLoadingBoundary = existingSpecialFiles.some(file => /(^|[/\\])loading\.[^.]+$/.test(file));
|
|
746
|
+
// Compute the ancestor client-reference closure for EVERY page, not just loading.js routes. Streaming
|
|
747
|
+
// isolates each Suspense boundary and drops client-provider context from ancestor layouts, so the
|
|
748
|
+
// render-time stream gate keys off this list - a route with in-page user Suspense boundaries but no
|
|
749
|
+
// loading.js still streams as long as no ancestor layout/template ships a client reference.
|
|
750
|
+
const ancestorClientReferences = collectClientReferences(options.context, [
|
|
751
|
+
...options.layoutFiles,
|
|
752
|
+
...options.templateFiles,
|
|
753
|
+
]);
|
|
754
|
+
return {
|
|
755
|
+
...(hasLoadingBoundary ? { hasLoadingBoundary } : {}),
|
|
756
|
+
...(ancestorClientReferences.length > 0
|
|
757
|
+
? { ancestorClientReferences: ancestorClientReferences.map(reference => reference.id) }
|
|
758
|
+
: {}),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function hasStaticParamsExport(source: string) {
|
|
763
|
+
const searchable = stripCommentsAndStrings(source);
|
|
764
|
+
return (
|
|
765
|
+
/\bexport\s+(?:async\s+)?function\s+(?:params|generateStaticParams)\b/.test(searchable) ||
|
|
766
|
+
/\bexport\s+const\s+(?:params|generateStaticParams)\b/.test(searchable)
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** All `@slot` directories on the walk from the app root to the route's dir. */
|
|
771
|
+
function chainSlotDirs(appPath: string, routeFilePath: string) {
|
|
772
|
+
const dirs: string[] = [];
|
|
773
|
+
let dir = path.dirname(routeFilePath);
|
|
774
|
+
while (dir.startsWith(appPath)) {
|
|
775
|
+
dirs.push(...slotDirectoriesIn(dir));
|
|
776
|
+
if (dir === appPath) break;
|
|
777
|
+
dir = path.dirname(dir);
|
|
778
|
+
}
|
|
779
|
+
return dirs.reverse();
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Every convention file in the given slot trees (recursive). */
|
|
783
|
+
function slotTreeFiles(slotDirs: string[]) {
|
|
784
|
+
const files: string[] = [];
|
|
785
|
+
const pattern = slotConventionPattern();
|
|
786
|
+
for (const dir of slotDirs) {
|
|
787
|
+
for (const file of listFilesSync(dir)) {
|
|
788
|
+
if (pattern.test(toPosixPath(file))) files.push(file);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return files;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Second scan pass over pages that live inside `@slot` directories. Each slot
|
|
796
|
+
* page implies a URL (its owner path plus its slot-internal path). A page with
|
|
797
|
+
* an interception marker produces an interception entry (matched only on soft
|
|
798
|
+
* navigation); other slot pages make their URL routable via a synthetic entry
|
|
799
|
+
* whose children tree renders the nearest `default.*` (hard-nav semantics)
|
|
800
|
+
* while the slot content resolves at render time.
|
|
801
|
+
*/
|
|
802
|
+
function appendSlotDerivedRoutes(
|
|
803
|
+
context: ScanContext,
|
|
804
|
+
routes: RouteManifestEntry[],
|
|
805
|
+
slotPages: { file: string; relative: string }[],
|
|
806
|
+
table: TableFlags,
|
|
807
|
+
) {
|
|
808
|
+
const appPath = context.appPath;
|
|
809
|
+
const patterns = new Set(routes.filter(route => !route.interception).map(route => route.pattern));
|
|
810
|
+
const interceptionKeys = new Set(
|
|
811
|
+
routes
|
|
812
|
+
.filter(route => route.interception)
|
|
813
|
+
.map(route => `${route.pattern}#${route.interception!.base}`),
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
const candidates = slotPages
|
|
817
|
+
.map(({ file, relative }) => {
|
|
818
|
+
const segments = relative.split('/');
|
|
819
|
+
const slotIndex = segments.findIndex(isSlotSegment);
|
|
820
|
+
const slotDir = path.join(appPath, ...segments.slice(0, slotIndex + 1));
|
|
821
|
+
return {
|
|
822
|
+
file,
|
|
823
|
+
relative,
|
|
824
|
+
slotDir,
|
|
825
|
+
ownerDir: path.dirname(slotDir),
|
|
826
|
+
parts: routeParts(relative.replace(pageOrRouteTrailing(), '')),
|
|
827
|
+
innerNames: segments
|
|
828
|
+
.slice(slotIndex + 1, -1)
|
|
829
|
+
.filter(name => !isSlotSegment(name) && !isGroupSegment(name)),
|
|
830
|
+
};
|
|
831
|
+
})
|
|
832
|
+
// Deepest owner first: a URL reachable through several slots anchors its
|
|
833
|
+
// layout chain at the most specific segment.
|
|
834
|
+
.sort((a, b) => b.ownerDir.length - a.ownerDir.length);
|
|
835
|
+
|
|
836
|
+
for (const candidate of candidates) {
|
|
837
|
+
const { parts } = candidate;
|
|
838
|
+
if (parts.interception) {
|
|
839
|
+
const key = `${parts.pattern}#${parts.interception.base}`;
|
|
840
|
+
if (interceptionKeys.has(key)) continue;
|
|
841
|
+
interceptionKeys.add(key);
|
|
842
|
+
// Never rendered directly: a matching soft navigation renders the
|
|
843
|
+
// CURRENT page (the from-path's entry) and this slot resolves the
|
|
844
|
+
// interceptor during slot rendering. Kept minimal on purpose.
|
|
845
|
+
routes.push(
|
|
846
|
+
withDeferredFacts(
|
|
847
|
+
{
|
|
848
|
+
id: `intercept-${sanitizeIdPart(candidate.relative.replace(pageOrRouteTrailing(), ''))}`,
|
|
849
|
+
kind: 'page',
|
|
850
|
+
route: parts.route,
|
|
851
|
+
pattern: parts.pattern,
|
|
852
|
+
file: candidate.file,
|
|
853
|
+
params: parts.params,
|
|
854
|
+
catchAll: parts.catchAll,
|
|
855
|
+
...(parts.catchAllOptional ? { catchAllOptional: true } : {}),
|
|
856
|
+
interception: { ...parts.interception, slotDir: candidate.slotDir },
|
|
857
|
+
},
|
|
858
|
+
() => ({
|
|
859
|
+
...emptyDerivedFacts,
|
|
860
|
+
...routerEntryFact(context, {
|
|
861
|
+
kind: 'page',
|
|
862
|
+
client: false,
|
|
863
|
+
clientReferences: [],
|
|
864
|
+
sourceFiles: [],
|
|
865
|
+
slotInterceptor: true,
|
|
866
|
+
table,
|
|
867
|
+
}),
|
|
868
|
+
}),
|
|
869
|
+
),
|
|
870
|
+
);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (patterns.has(parts.pattern)) continue;
|
|
875
|
+
patterns.add(parts.pattern);
|
|
876
|
+
|
|
877
|
+
const childrenDefault = childrenDefaultFile(candidate.ownerDir, candidate.innerNames);
|
|
878
|
+
// The anchor only positions the entry in the app tree (layouts, templates,
|
|
879
|
+
// boundaries, slot chain). Prefer a real file: the children default, then an
|
|
880
|
+
// explicit `@children` page, then the owner's own page. A non-existent
|
|
881
|
+
// `<owner>/page.tsx` would break the build when the URL is served purely by
|
|
882
|
+
// slots, so fall back to the slot page itself as a last resort.
|
|
883
|
+
const anchor =
|
|
884
|
+
childrenDefault ??
|
|
885
|
+
slotConventionFile(path.join(candidate.ownerDir, '@children'), 'page') ??
|
|
886
|
+
slotConventionFile(candidate.ownerDir, 'page') ??
|
|
887
|
+
candidate.file;
|
|
888
|
+
const layoutFiles = findLayouts(appPath, anchor);
|
|
889
|
+
const templateFiles = findConventionFiles(appPath, anchor, 'template');
|
|
890
|
+
const specialFiles = boundaryConventions().flatMap(name =>
|
|
891
|
+
findConventionFiles(appPath, anchor, name),
|
|
892
|
+
);
|
|
893
|
+
const slotDirs = chainSlotDirs(appPath, anchor);
|
|
894
|
+
const slotFiles = slotTreeFiles(slotDirs);
|
|
895
|
+
const extraBoundaries = new Set(extraBoundaryConventionNames());
|
|
896
|
+
const forbiddenPaths =
|
|
897
|
+
extraBoundaries.has('forbidden')
|
|
898
|
+
? findConventionFiles(appPath, anchor, 'forbidden').filter(item => existsSync(item))
|
|
899
|
+
: [];
|
|
900
|
+
const unauthorizedPaths =
|
|
901
|
+
extraBoundaries.has('unauthorized')
|
|
902
|
+
? findConventionFiles(appPath, anchor, 'unauthorized').filter(item => existsSync(item))
|
|
903
|
+
: [];
|
|
904
|
+
const templatePaths = templateFiles.filter(item => existsSync(item));
|
|
905
|
+
|
|
906
|
+
routes.push(
|
|
907
|
+
withDeferredFacts(
|
|
908
|
+
{
|
|
909
|
+
id: `slot-${routeId(parts.route || '/')}`,
|
|
910
|
+
kind: 'page',
|
|
911
|
+
route: parts.route,
|
|
912
|
+
pattern: parts.pattern,
|
|
913
|
+
file: anchor,
|
|
914
|
+
params: parts.params,
|
|
915
|
+
catchAll: parts.catchAll,
|
|
916
|
+
...(parts.catchAllOptional ? { catchAllOptional: true } : {}),
|
|
917
|
+
...(templatePaths.length > 0 ? { templateFiles: templatePaths } : {}),
|
|
918
|
+
...(slotDirs.length > 0 ? { slotDirs } : {}),
|
|
919
|
+
synthetic: true,
|
|
920
|
+
syntheticSlotDir: candidate.slotDir,
|
|
921
|
+
...(childrenDefault ? { childrenDefault } : {}),
|
|
922
|
+
...(forbiddenPaths.length > 0 ? { forbiddenFiles: forbiddenPaths } : {}),
|
|
923
|
+
...(unauthorizedPaths.length > 0 ? { unauthorizedFiles: unauthorizedPaths } : {}),
|
|
924
|
+
},
|
|
925
|
+
() =>
|
|
926
|
+
derivedRouteFacts(context, {
|
|
927
|
+
leadFiles: childrenDefault ? [childrenDefault] : [],
|
|
928
|
+
layoutFiles,
|
|
929
|
+
templateFiles,
|
|
930
|
+
specialFiles,
|
|
931
|
+
slotFiles,
|
|
932
|
+
clientFile: childrenDefault,
|
|
933
|
+
table,
|
|
934
|
+
}),
|
|
935
|
+
),
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Facts shared by every derived entry: dynamic, request-rendered, never prerendered. */
|
|
941
|
+
const emptyDerivedFacts = {
|
|
942
|
+
mode: 'dynamic',
|
|
943
|
+
hasStaticParams: false,
|
|
944
|
+
usesRequest: true,
|
|
945
|
+
client: false,
|
|
946
|
+
clientReferences: [],
|
|
947
|
+
cssImports: [],
|
|
948
|
+
sourceFiles: [],
|
|
949
|
+
} satisfies RouteFacts;
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Content facts of a synthetic slot / default entry. Its tree is the convention
|
|
953
|
+
* chain around the anchor — the entry has no page file of its own to scan.
|
|
954
|
+
*/
|
|
955
|
+
function derivedRouteFacts(
|
|
956
|
+
context: ScanContext,
|
|
957
|
+
options: {
|
|
958
|
+
/** Files that lead the entry tree (children default) or ARE it (a default.tsx). */
|
|
959
|
+
leadFiles: string[];
|
|
960
|
+
layoutFiles: string[];
|
|
961
|
+
templateFiles: string[];
|
|
962
|
+
specialFiles: string[];
|
|
963
|
+
slotFiles: string[];
|
|
964
|
+
/** File whose `'use client'` directive decides the entry's own `client` flag. */
|
|
965
|
+
clientFile?: string;
|
|
966
|
+
/** Lead file also participates in CSS order as the route file (default entries). */
|
|
967
|
+
leadIsRouteFile?: boolean;
|
|
968
|
+
table: TableFlags;
|
|
969
|
+
},
|
|
970
|
+
): RouteFacts {
|
|
971
|
+
const { leadFiles, layoutFiles, templateFiles, specialFiles, slotFiles } = options;
|
|
972
|
+
const entryFiles = [...leadFiles, ...layoutFiles, ...templateFiles, ...specialFiles, ...slotFiles];
|
|
973
|
+
const sourceFiles = collectSourceFiles(context, entryFiles);
|
|
974
|
+
const clientReferences = collectClientReferences(context, entryFiles);
|
|
975
|
+
const cssImports = collectCssImports(
|
|
976
|
+
context,
|
|
977
|
+
cssEntryOrder({
|
|
978
|
+
...(options.leadIsRouteFile ? { routeFile: leadFiles[0] } : { leadFiles }),
|
|
979
|
+
layoutFiles,
|
|
980
|
+
templateFiles,
|
|
981
|
+
specialFiles,
|
|
982
|
+
slotFiles,
|
|
983
|
+
}),
|
|
984
|
+
{ route: true },
|
|
985
|
+
);
|
|
986
|
+
attachLazyReferenceCss(context, clientReferences);
|
|
987
|
+
const client = options.clientFile ? isClientModule(context, options.clientFile) : false;
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
...emptyDerivedFacts,
|
|
991
|
+
client,
|
|
992
|
+
clientReferences,
|
|
993
|
+
cssImports,
|
|
994
|
+
sourceFiles,
|
|
995
|
+
...routerEntryFact(context, {
|
|
996
|
+
kind: 'page',
|
|
997
|
+
client,
|
|
998
|
+
clientReferences,
|
|
999
|
+
sourceFiles,
|
|
1000
|
+
slotInterceptor: false,
|
|
1001
|
+
table: options.table,
|
|
1002
|
+
}),
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Second pass: a segment directory that owns a `default.tsx` but no `page.tsx`
|
|
1008
|
+
* still renders (its `default` is the children fallback for that active but
|
|
1009
|
+
* pageless segment). Without a route entry the request would fall through to a
|
|
1010
|
+
* higher catch-all, so we synthesize one whose page file IS the `default.tsx`.
|
|
1011
|
+
* Its route/pattern are more specific than a higher `[[...catchAll]]`, so the
|
|
1012
|
+
* matcher and `routeRank` prefer the default segment (Next: default > catch-all).
|
|
1013
|
+
* Skipped when a real `page.tsx` already produced an entry for the same pattern.
|
|
1014
|
+
*/
|
|
1015
|
+
function appendDefaultDerivedRoutes(
|
|
1016
|
+
context: ScanContext,
|
|
1017
|
+
routes: RouteManifestEntry[],
|
|
1018
|
+
defaultPages: { file: string; relative: string }[],
|
|
1019
|
+
table: TableFlags,
|
|
1020
|
+
) {
|
|
1021
|
+
const appPath = context.appPath;
|
|
1022
|
+
const patterns = new Set(routes.filter(route => !route.interception).map(route => route.pattern));
|
|
1023
|
+
|
|
1024
|
+
for (const { file, relative } of defaultPages) {
|
|
1025
|
+
const dir = path.dirname(file);
|
|
1026
|
+
// A sibling page.tsx means the segment already has a normal entry.
|
|
1027
|
+
if (conventionFileName(dir, 'page')) continue;
|
|
1028
|
+
const parts = routeParts(relative.replace(pageOrRouteTrailing(), ''));
|
|
1029
|
+
if (parts.interception) continue;
|
|
1030
|
+
if (patterns.has(parts.pattern)) continue;
|
|
1031
|
+
patterns.add(parts.pattern);
|
|
1032
|
+
|
|
1033
|
+
const layoutFiles = findLayouts(appPath, file);
|
|
1034
|
+
const templateFiles = findConventionFiles(appPath, file, 'template');
|
|
1035
|
+
const specialFiles = boundaryConventions().flatMap(name =>
|
|
1036
|
+
findConventionFiles(appPath, file, name),
|
|
1037
|
+
);
|
|
1038
|
+
const slotDirs = chainSlotDirs(appPath, file);
|
|
1039
|
+
const slotFiles = slotTreeFiles(slotDirs);
|
|
1040
|
+
const extraBoundaries = new Set(extraBoundaryConventionNames());
|
|
1041
|
+
const forbiddenPaths =
|
|
1042
|
+
extraBoundaries.has('forbidden')
|
|
1043
|
+
? findConventionFiles(appPath, file, 'forbidden').filter(item => existsSync(item))
|
|
1044
|
+
: [];
|
|
1045
|
+
const unauthorizedPaths =
|
|
1046
|
+
extraBoundaries.has('unauthorized')
|
|
1047
|
+
? findConventionFiles(appPath, file, 'unauthorized').filter(item => existsSync(item))
|
|
1048
|
+
: [];
|
|
1049
|
+
const templatePaths = templateFiles.filter(item => existsSync(item));
|
|
1050
|
+
|
|
1051
|
+
routes.push(
|
|
1052
|
+
withDeferredFacts(
|
|
1053
|
+
{
|
|
1054
|
+
id: `default-${routeId(parts.route || '/')}`,
|
|
1055
|
+
kind: 'page',
|
|
1056
|
+
route: parts.route,
|
|
1057
|
+
pattern: parts.pattern,
|
|
1058
|
+
file,
|
|
1059
|
+
params: parts.params,
|
|
1060
|
+
catchAll: parts.catchAll,
|
|
1061
|
+
...(parts.catchAllOptional ? { catchAllOptional: true } : {}),
|
|
1062
|
+
...(templatePaths.length > 0 ? { templateFiles: templatePaths } : {}),
|
|
1063
|
+
...(slotDirs.length > 0 ? { slotDirs } : {}),
|
|
1064
|
+
...(forbiddenPaths.length > 0 ? { forbiddenFiles: forbiddenPaths } : {}),
|
|
1065
|
+
...(unauthorizedPaths.length > 0 ? { unauthorizedFiles: unauthorizedPaths } : {}),
|
|
1066
|
+
},
|
|
1067
|
+
() =>
|
|
1068
|
+
derivedRouteFacts(context, {
|
|
1069
|
+
leadFiles: [file],
|
|
1070
|
+
leadIsRouteFile: true,
|
|
1071
|
+
layoutFiles,
|
|
1072
|
+
templateFiles,
|
|
1073
|
+
specialFiles,
|
|
1074
|
+
slotFiles,
|
|
1075
|
+
clientFile: file,
|
|
1076
|
+
table,
|
|
1077
|
+
}),
|
|
1078
|
+
),
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
export function findLayouts(appPath: string, routeFilePath: string) {
|
|
1084
|
+
return findConventionFiles(appPath, routeFilePath, 'layout');
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* CSS imported by the app's ROOT `not-found.*` (and its module graph), in
|
|
1089
|
+
* source order, excluding the root-layout global CSS (which is served
|
|
1090
|
+
* separately via `/assets/global.css`). Used to give the synthetic 404 route
|
|
1091
|
+
* its own stylesheet: an unmatched URL rendering the root not-found must ship
|
|
1092
|
+
* BOTH the root layout CSS (the global sheet) AND the not-found's own CSS.
|
|
1093
|
+
* Returns [] when the app has no root not-found file (built-in fallback).
|
|
1094
|
+
*/
|
|
1095
|
+
export async function collectNotFoundCss(appPath: string): Promise<string[]> {
|
|
1096
|
+
const notFoundFile = conventionFileName(appPath, 'not-found');
|
|
1097
|
+
if (!notFoundFile) return [];
|
|
1098
|
+
return collectFileCss(appPath, [notFoundFile]);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* CSS imported by `files` (and their module graphs), in source order. By default the root-layout global CSS
|
|
1103
|
+
* is excluded, served separately via `/assets/global.css`; `includeGlobalCss` keeps it - for
|
|
1104
|
+
* document-replacing conventions (global-not-found) that render WITHOUT the root layout, so a stylesheet
|
|
1105
|
+
* shared with the root layout must still ship in their own chunk. Used to give render-time synthetic routes
|
|
1106
|
+
* their own stylesheet outside the scan-built manifest.
|
|
1107
|
+
*/
|
|
1108
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
1109
|
+
export async function collectFileCss(
|
|
1110
|
+
appPath: string,
|
|
1111
|
+
files: string[],
|
|
1112
|
+
options: { includeGlobalCss?: boolean } = {},
|
|
1113
|
+
): Promise<string[]> {
|
|
1114
|
+
const existing = files.filter(file => existsSync(file));
|
|
1115
|
+
if (existing.length === 0) return [];
|
|
1116
|
+
const context = createScanContext(
|
|
1117
|
+
appPath,
|
|
1118
|
+
options.includeGlobalCss ? () => new Set<string>() : undefined,
|
|
1119
|
+
);
|
|
1120
|
+
return collectCssImports(context, existing);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Walk from the route's directory up to `appPath`, returning one convention file per segment ordered
|
|
1125
|
+
* root-to-leaf. `name` may be a bare base (`'layout'`) or carry an extension - a known extension is stripped
|
|
1126
|
+
* so the full extension list is tried. Each segment resolves to the first existing `<base>.<ext>`; when none
|
|
1127
|
+
* exists the `.tsx` candidate is returned so existing callers that filter by `existsSync` keep
|
|
1128
|
+
* byte-identical behavior.
|
|
1129
|
+
*/
|
|
1130
|
+
export function findConventionFiles(appPath: string, routeFilePath: string, name: string) {
|
|
1131
|
+
const base = conventionBase(name);
|
|
1132
|
+
const files: string[] = [];
|
|
1133
|
+
let dir = path.dirname(routeFilePath);
|
|
1134
|
+
|
|
1135
|
+
while (dir.startsWith(appPath)) {
|
|
1136
|
+
files.push(conventionFileName(dir, base) ?? path.join(dir, `${base}.tsx`));
|
|
1137
|
+
if (dir === appPath) break;
|
|
1138
|
+
dir = path.dirname(dir);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
return files.reverse();
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function conventionBase(name: string) {
|
|
1145
|
+
const match = new RegExp(`\\.(${pageExtensions().join('|')})$`).exec(name);
|
|
1146
|
+
return match ? name.slice(0, -match[0].length) : name;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** App-root `global-error.*` file, if present (BuildManifest.globalErrorFile). */
|
|
1150
|
+
export function findGlobalError(appPath: string) {
|
|
1151
|
+
return conventionFileName(appPath, globalErrorBase);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
export function matchRoute(routes: RouteManifestEntry[], pathname: string) {
|
|
1155
|
+
// Trailing-slash tolerance: '/route/' matches the same entry as '/route'
|
|
1156
|
+
// (Next redirects these away; static file serving already accepts both).
|
|
1157
|
+
const normalized = normalizePathname(pathname);
|
|
1158
|
+
for (const route of routes) {
|
|
1159
|
+
// Interception entries never match a plain (hard) request; they apply
|
|
1160
|
+
// only through matchInterception on soft navigations.
|
|
1161
|
+
if (route.interception) continue;
|
|
1162
|
+
const match = routeRegex(route).exec(normalized);
|
|
1163
|
+
if (!match) continue;
|
|
1164
|
+
return { route, params: routeMatchParams(route, match) };
|
|
1165
|
+
}
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
export function normalizePathname(pathname: string) {
|
|
1170
|
+
return pathname.length > 1 ? pathname.replace(/\/+$/, '') || '/' : pathname;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function routeMatchParams(route: RouteManifestEntry, match: RegExpExecArray) {
|
|
1174
|
+
const params: Record<string, RouteParamValue> = {};
|
|
1175
|
+
route.params.forEach((name, index) => {
|
|
1176
|
+
const decoded = safeDecode(match[index + 1] ?? '');
|
|
1177
|
+
// A runtime request whose segment IS the dynamic placeholder for its own
|
|
1178
|
+
// param (`[slug]`, whether sent raw or as `%5Bslug%5D`) is Next's "params
|
|
1179
|
+
// placeholder": it must surface the ENCODED placeholder (`%5Bslug%5D`), not
|
|
1180
|
+
// a decoded `[slug]`. A decoded `[slug]` reads as a fallback param and would
|
|
1181
|
+
// trigger a fallback-shell render (failing without a parent Suspense
|
|
1182
|
+
// boundary); Next keeps it URL-encoded to render at runtime instead.
|
|
1183
|
+
// Fallback-shell generation injects its placeholder params through a
|
|
1184
|
+
// separate build path, so this only affects live requests.
|
|
1185
|
+
params[name] = decoded === `[${name}]` ? encodeURIComponent(decoded) : decoded;
|
|
1186
|
+
});
|
|
1187
|
+
if (route.catchAll) {
|
|
1188
|
+
params[route.catchAll] = (match[route.params.length + 1] ?? '')
|
|
1189
|
+
.split('/')
|
|
1190
|
+
.filter(Boolean)
|
|
1191
|
+
.map(safeDecode);
|
|
1192
|
+
}
|
|
1193
|
+
return params;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export interface InterceptionRouteMatch {
|
|
1197
|
+
route: RouteManifestEntry;
|
|
1198
|
+
params: Record<string, RouteParamValue>;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Match a soft navigation against interception entries: the entry's target
|
|
1203
|
+
* pattern must match the destination and the navigation must originate at or
|
|
1204
|
+
* below the interceptor's base (the children path the current document
|
|
1205
|
+
* rendered from). The deepest base wins. A no-op when destination equals the
|
|
1206
|
+
* origin (a refresh re-renders whatever produced the current document).
|
|
1207
|
+
*/
|
|
1208
|
+
export function matchInterception(
|
|
1209
|
+
routes: RouteManifestEntry[],
|
|
1210
|
+
pathname: string,
|
|
1211
|
+
fromPath: string | undefined,
|
|
1212
|
+
): InterceptionRouteMatch | null {
|
|
1213
|
+
if (!fromPath) return null;
|
|
1214
|
+
const target = normalizePathname(pathname);
|
|
1215
|
+
const from = normalizePathname(fromPath);
|
|
1216
|
+
if (target === from) return null;
|
|
1217
|
+
let best: InterceptionRouteMatch | null = null;
|
|
1218
|
+
let bestDepth = -1;
|
|
1219
|
+
for (const route of routes) {
|
|
1220
|
+
const interception = route.interception;
|
|
1221
|
+
if (!interception) continue;
|
|
1222
|
+
const match = routeRegex(route).exec(target);
|
|
1223
|
+
if (!match) continue;
|
|
1224
|
+
const basePattern = interception.basePattern ? `/${interception.basePattern}` : '';
|
|
1225
|
+
if (!new RegExp(`^${basePattern}(?:/.*)?$`).test(from)) continue;
|
|
1226
|
+
const depth = interception.base.split('/').filter(Boolean).length;
|
|
1227
|
+
if (depth <= bestDepth) continue;
|
|
1228
|
+
bestDepth = depth;
|
|
1229
|
+
best = { route, params: routeMatchParams(route, match) };
|
|
1230
|
+
}
|
|
1231
|
+
return best;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
export interface RouteRenderSelection {
|
|
1235
|
+
route: RouteManifestEntry;
|
|
1236
|
+
params: Record<string, RouteParamValue>;
|
|
1237
|
+
/** Pathname the children tree renders from (normalized). */
|
|
1238
|
+
childrenPath: string;
|
|
1239
|
+
/** Internal pathname after rewrites, used to resolve interception slots. */
|
|
1240
|
+
targetPath: string;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Pick the entry a page request renders. Hard requests match plainly. Soft navigations first consult
|
|
1245
|
+
* interception entries; a slot interception (and a synthetic slot URL reached with known origin) renders the
|
|
1246
|
+
* ORIGIN's entry as host - the current page stays while the slot content changes - with the children tree
|
|
1247
|
+
* anchored at `childrenPath`.
|
|
1248
|
+
*/
|
|
1249
|
+
export function selectRouteForRequest(
|
|
1250
|
+
routes: RouteManifestEntry[],
|
|
1251
|
+
pathname: string,
|
|
1252
|
+
nav?: import('../types').NavState,
|
|
1253
|
+
): RouteRenderSelection | null {
|
|
1254
|
+
const target = normalizePathname(pathname);
|
|
1255
|
+
if (nav) {
|
|
1256
|
+
const intercepted = matchInterception(routes, target, nav.children);
|
|
1257
|
+
if (intercepted) {
|
|
1258
|
+
if (!intercepted.route.interception?.slotDir) {
|
|
1259
|
+
return {
|
|
1260
|
+
route: intercepted.route,
|
|
1261
|
+
params: intercepted.params,
|
|
1262
|
+
childrenPath: target,
|
|
1263
|
+
targetPath: target,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
const host = hostMatch(routes, nav.children);
|
|
1267
|
+
if (host) return { ...host, targetPath: target };
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
const matched = matchRoute(routes, target);
|
|
1271
|
+
if (!matched) return null;
|
|
1272
|
+
if (nav && matched.route.synthetic && nav.children) {
|
|
1273
|
+
const primary = matchRoute(routes.filter(route => !route.synthetic), target);
|
|
1274
|
+
const syntheticOwner = matched.route.syntheticSlotDir
|
|
1275
|
+
? path.dirname(matched.route.syntheticSlotDir)
|
|
1276
|
+
: undefined;
|
|
1277
|
+
const primaryFromOwner = primary && syntheticOwner
|
|
1278
|
+
? path.relative(syntheticOwner, primary.route.file)
|
|
1279
|
+
: undefined;
|
|
1280
|
+
if (
|
|
1281
|
+
primary &&
|
|
1282
|
+
primaryFromOwner !== undefined &&
|
|
1283
|
+
primaryFromOwner !== '..' &&
|
|
1284
|
+
!primaryFromOwner.startsWith(`..${path.sep}`)
|
|
1285
|
+
) {
|
|
1286
|
+
return {
|
|
1287
|
+
route: primary.route,
|
|
1288
|
+
params: primary.params,
|
|
1289
|
+
childrenPath: target,
|
|
1290
|
+
targetPath: target,
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
const host = hostMatch(routes, nav.children);
|
|
1294
|
+
if (
|
|
1295
|
+
host &&
|
|
1296
|
+
host.route !== matched.route &&
|
|
1297
|
+
matched.route.syntheticSlotDir &&
|
|
1298
|
+
host.route.slotDirs?.includes(matched.route.syntheticSlotDir)
|
|
1299
|
+
) {
|
|
1300
|
+
return { ...host, targetPath: target };
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
route: matched.route,
|
|
1305
|
+
params: matched.params,
|
|
1306
|
+
childrenPath: target,
|
|
1307
|
+
targetPath: target,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Navigation headers from the client soft-nav runtime: `x-pnext-soft-nav`
|
|
1313
|
+
* marks the fetch and `x-pnext-nav-state` carries the current document's
|
|
1314
|
+
* parallel-route state (URI-encoded JSON).
|
|
1315
|
+
*/
|
|
1316
|
+
export function parseNavState(request: Request): NavState | undefined {
|
|
1317
|
+
if (!request.headers.get('x-pnext-soft-nav')) return undefined;
|
|
1318
|
+
const raw = request.headers.get('x-pnext-nav-state');
|
|
1319
|
+
if (!raw) return {};
|
|
1320
|
+
try {
|
|
1321
|
+
const parsed = JSON.parse(decodeURIComponent(raw)) as unknown;
|
|
1322
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
1323
|
+
} catch {
|
|
1324
|
+
// Malformed state degrades to a plain soft navigation.
|
|
1325
|
+
}
|
|
1326
|
+
return {};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function hostMatch(
|
|
1330
|
+
routes: RouteManifestEntry[],
|
|
1331
|
+
fromPath: string | undefined,
|
|
1332
|
+
): RouteRenderSelection | null {
|
|
1333
|
+
if (!fromPath) return null;
|
|
1334
|
+
const from = normalizePathname(fromPath);
|
|
1335
|
+
const host = matchRoute(routes, from);
|
|
1336
|
+
if (host?.route.kind !== 'page') return null;
|
|
1337
|
+
return { route: host.route, params: host.params, childrenPath: from, targetPath: from };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const routeRegexCache = new WeakMap<RouteManifestEntry, RegExp>();
|
|
1341
|
+
|
|
1342
|
+
function routeRegex(route: RouteManifestEntry) {
|
|
1343
|
+
let regex = routeRegexCache.get(route);
|
|
1344
|
+
if (!regex) {
|
|
1345
|
+
regex = new RegExp(`^${route.pattern}$`);
|
|
1346
|
+
routeRegexCache.set(route, regex);
|
|
1347
|
+
}
|
|
1348
|
+
return regex;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
interface RouteSegmentToken {
|
|
1352
|
+
route: string;
|
|
1353
|
+
/** Regex piece; absent for the optional catch-all (assembled specially). */
|
|
1354
|
+
pattern?: string;
|
|
1355
|
+
param?: string;
|
|
1356
|
+
catchAll?: string;
|
|
1357
|
+
catchAllOptional?: boolean;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function segmentTokenOf(bare: string): RouteSegmentToken {
|
|
1361
|
+
const optionalCatchAllMatch = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(bare);
|
|
1362
|
+
if (optionalCatchAllMatch?.[1]) {
|
|
1363
|
+
const name = optionalCatchAllMatch[1];
|
|
1364
|
+
return { route: `:${name}*`, catchAll: name, catchAllOptional: true };
|
|
1365
|
+
}
|
|
1366
|
+
const catchAllMatch = /^\[\.\.\.([^\]]+)\]$/.exec(bare);
|
|
1367
|
+
if (catchAllMatch?.[1]) {
|
|
1368
|
+
return { route: `:${catchAllMatch[1]}*`, pattern: '(.*)', catchAll: catchAllMatch[1] };
|
|
1369
|
+
}
|
|
1370
|
+
const paramMatch = /^\[([^\]]+)\]$/.exec(bare);
|
|
1371
|
+
if (paramMatch?.[1]) {
|
|
1372
|
+
return { route: `:${paramMatch[1]}`, pattern: '([^/]+)', param: paramMatch[1] };
|
|
1373
|
+
}
|
|
1374
|
+
const literal = decodeLiteralSegment(bare);
|
|
1375
|
+
return { route: literal, pattern: escapeRegex(literal) };
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// A path is private when any of its directory segments begins with `_`, except
|
|
1379
|
+
// route groups `(name)`, slots `@slot`, and `%5F`-encoded segments (which
|
|
1380
|
+
// decode to a routable leading underscore). The filename segment is excluded.
|
|
1381
|
+
function isPrivateAppPath(relative: string): boolean {
|
|
1382
|
+
const segments = relative.split('/');
|
|
1383
|
+
return segments
|
|
1384
|
+
.slice(0, -1)
|
|
1385
|
+
.some(
|
|
1386
|
+
segment => segment.startsWith('_') && !isGroupSegment(segment) && !isSlotSegment(segment),
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// A literal (non-dynamic) path segment: `%5F` decodes to `_` so a directory
|
|
1391
|
+
// named `%5Ffoo` on disk routes as `/_foo`. Other percent escapes are left
|
|
1392
|
+
// as-is (the router decodes params at match time).
|
|
1393
|
+
function decodeLiteralSegment(segment: string): string {
|
|
1394
|
+
return segment.replace(/%5[Ff]/g, '_');
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function routeParts(routeDir: string): RouteParts {
|
|
1398
|
+
const segments = routeDir.split('/').filter(Boolean);
|
|
1399
|
+
let tokens: RouteSegmentToken[] = [];
|
|
1400
|
+
let interception: RouteParts['interception'];
|
|
1401
|
+
|
|
1402
|
+
for (const segment of segments) {
|
|
1403
|
+
// Route group `(name)`: organizational only. `@slot`: no path segment.
|
|
1404
|
+
if (isGroupSegment(segment) || isSlotSegment(segment)) continue;
|
|
1405
|
+
|
|
1406
|
+
const marker = interceptionMarkerOf(segment);
|
|
1407
|
+
let bare = segment;
|
|
1408
|
+
if (marker) {
|
|
1409
|
+
// The interception target rewinds `levels` URL segments from the
|
|
1410
|
+
// marker's position; the segments before the marker form the base the
|
|
1411
|
+
// interceptor renders within.
|
|
1412
|
+
bare = segment.slice(marker.length);
|
|
1413
|
+
const base = tokens.slice();
|
|
1414
|
+
const levels = interceptionMarkerLevels(marker);
|
|
1415
|
+
tokens =
|
|
1416
|
+
levels === Number.POSITIVE_INFINITY
|
|
1417
|
+
? []
|
|
1418
|
+
: tokens.slice(0, Math.max(0, tokens.length - levels));
|
|
1419
|
+
interception = {
|
|
1420
|
+
marker,
|
|
1421
|
+
base: `/${base.map(token => token.route).join('/')}`.replace(/\/$/, '') || '/',
|
|
1422
|
+
basePattern: base.map(token => token.pattern ?? '(.*)').join('/'),
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
tokens.push(segmentTokenOf(bare));
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const params = tokens.filter(token => token.param).map(token => token.param!);
|
|
1430
|
+
const catchAllToken = tokens.find(token => token.catchAll);
|
|
1431
|
+
const routeSegments = tokens.map(token => token.route);
|
|
1432
|
+
const patternSegments = tokens
|
|
1433
|
+
.filter(token => !token.catchAllOptional)
|
|
1434
|
+
.map(token => token.pattern!);
|
|
1435
|
+
|
|
1436
|
+
const route = `/${routeSegments.join('/')}`.replace(/\/$/, '') || '/';
|
|
1437
|
+
let pattern: string;
|
|
1438
|
+
if (catchAllToken?.catchAllOptional) {
|
|
1439
|
+
// `[[...slug]]` matches the base path and any nested path: the whole
|
|
1440
|
+
// trailing group (including its leading slash) is optional.
|
|
1441
|
+
const prefix = patternSegments.length > 0 ? `/${patternSegments.join('/')}` : '';
|
|
1442
|
+
pattern = `${prefix}(?:/(.*))?`;
|
|
1443
|
+
if (pattern === '(?:/(.*))?') pattern = '/?(?:(.*))?';
|
|
1444
|
+
} else {
|
|
1445
|
+
pattern = route === '/' ? '/' : `/${patternSegments.join('/')}`;
|
|
1446
|
+
}
|
|
1447
|
+
return {
|
|
1448
|
+
route,
|
|
1449
|
+
pattern,
|
|
1450
|
+
params,
|
|
1451
|
+
catchAll: catchAllToken?.catchAll,
|
|
1452
|
+
catchAllOptional: Boolean(catchAllToken?.catchAllOptional),
|
|
1453
|
+
interception,
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
interface DynamicMetadataRouteParts extends RouteParts {
|
|
1458
|
+
metadataRoute: MetadataRouteEntry;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function dynamicMetadataRouteParts(
|
|
1462
|
+
relative: string,
|
|
1463
|
+
source: string,
|
|
1464
|
+
): DynamicMetadataRouteParts | undefined {
|
|
1465
|
+
const name = path.basename(relative);
|
|
1466
|
+
const dir = path.dirname(relative) === '.' ? '' : path.dirname(relative);
|
|
1467
|
+
const root = dir === '';
|
|
1468
|
+
const extension = path.extname(name);
|
|
1469
|
+
const base = name.slice(0, -extension.length);
|
|
1470
|
+
const kind = dynamicMetadataKind(base, root);
|
|
1471
|
+
if (!kind) return undefined;
|
|
1472
|
+
|
|
1473
|
+
const prefix = routeParts(dir);
|
|
1474
|
+
const generatedParam = metadataGeneratedParam(kind, source);
|
|
1475
|
+
const final = dynamicMetadataFinalSegment(kind, base, dir, Boolean(generatedParam));
|
|
1476
|
+
return {
|
|
1477
|
+
...appendRouteParts(prefix, final.route, final.pattern, generatedParam ? [generatedParam] : []),
|
|
1478
|
+
metadataRoute: {
|
|
1479
|
+
kind,
|
|
1480
|
+
...(generatedParam ? { generatedParam } : {}),
|
|
1481
|
+
},
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function dynamicMetadataKind(base: string, root: boolean): MetadataRouteKind | undefined {
|
|
1486
|
+
if (base === 'robots') return root ? 'robots' : undefined;
|
|
1487
|
+
if (base === 'sitemap') return 'sitemap';
|
|
1488
|
+
if (base === 'manifest') return root ? 'manifest' : undefined;
|
|
1489
|
+
if (/^icon\d*$/.test(base)) return 'icon';
|
|
1490
|
+
if (/^apple-icon\d*$/.test(base)) return 'apple-icon';
|
|
1491
|
+
if (/^opengraph-image\d*$/.test(base)) return 'opengraph-image';
|
|
1492
|
+
if (/^twitter-image\d*$/.test(base)) return 'twitter-image';
|
|
1493
|
+
return undefined;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function metadataGeneratedParam(kind: MetadataRouteKind, source: string) {
|
|
1497
|
+
if (kind === 'sitemap' && /\bexport\s+(?:async\s+)?function\s+generateSitemaps\b/.test(source)) {
|
|
1498
|
+
return '__metadata_id__';
|
|
1499
|
+
}
|
|
1500
|
+
if (
|
|
1501
|
+
metadataImageKind(kind) &&
|
|
1502
|
+
/\bexport\s+(?:async\s+)?function\s+generateImageMetadata\b/.test(source)
|
|
1503
|
+
) {
|
|
1504
|
+
return '__metadata_id__';
|
|
1505
|
+
}
|
|
1506
|
+
return undefined;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function dynamicMetadataFinalSegment(
|
|
1510
|
+
kind: MetadataRouteKind,
|
|
1511
|
+
base: string,
|
|
1512
|
+
relativeDir: string,
|
|
1513
|
+
generated: boolean,
|
|
1514
|
+
) {
|
|
1515
|
+
if (kind === 'robots') return { route: 'robots.txt', pattern: 'robots\\.txt' };
|
|
1516
|
+
if (kind === 'manifest')
|
|
1517
|
+
return { route: 'manifest.webmanifest', pattern: 'manifest\\.webmanifest' };
|
|
1518
|
+
if (kind === 'sitemap') {
|
|
1519
|
+
return generated
|
|
1520
|
+
? { route: 'sitemap/:id.xml', pattern: 'sitemap/([^/]+)\\.xml' }
|
|
1521
|
+
: { route: 'sitemap.xml', pattern: 'sitemap\\.xml' };
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const suffix = metadataRouteSuffix(relativeDir);
|
|
1525
|
+
const imageBase = suffix ? `${base}-${suffix}` : base;
|
|
1526
|
+
return generated
|
|
1527
|
+
? { route: `${imageBase}/:id`, pattern: `${escapeRegex(imageBase)}/([^/]+)` }
|
|
1528
|
+
: { route: imageBase, pattern: escapeRegex(imageBase) };
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function appendRouteParts(parts: RouteParts, route: string, pattern: string, params: string[]) {
|
|
1532
|
+
const prefixRoute = parts.route === '/' ? '' : parts.route;
|
|
1533
|
+
const prefixPattern = parts.pattern === '/' ? '' : parts.pattern;
|
|
1534
|
+
return {
|
|
1535
|
+
...parts,
|
|
1536
|
+
route: `${prefixRoute}/${route}`.replace(/\/$/, '') || '/',
|
|
1537
|
+
pattern: `${prefixPattern}/${pattern}`,
|
|
1538
|
+
params: [...parts.params, ...params],
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function metadataImageKind(kind: MetadataRouteKind) {
|
|
1543
|
+
return (
|
|
1544
|
+
kind === 'icon' ||
|
|
1545
|
+
kind === 'apple-icon' ||
|
|
1546
|
+
kind === 'opengraph-image' ||
|
|
1547
|
+
kind === 'twitter-image'
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function metadataRouteUsesRequest(source: string) {
|
|
1552
|
+
// routeHandlerUsesRequest already consults the compat usageDetection seam
|
|
1553
|
+
// (next/headers, next/server connection(), ...).
|
|
1554
|
+
return routeHandlerUsesRequest(source);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function metadataRouteSuffix(relativeDir: string) {
|
|
1558
|
+
if (!relativeDir) return '';
|
|
1559
|
+
const segments = relativeDir.split('/').filter(Boolean);
|
|
1560
|
+
if (!segments.some(segment => isGroupSegment(segment) || isSlotSegment(segment))) return '';
|
|
1561
|
+
return djb2Hash(`/${relativeDir}`).toString(36).slice(0, 6);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function djb2Hash(value: string) {
|
|
1565
|
+
let hash = 5381;
|
|
1566
|
+
for (let i = 0; i < value.length; i++) {
|
|
1567
|
+
hash = ((hash << 5) + hash + value.charCodeAt(i)) & 0xffffffff;
|
|
1568
|
+
}
|
|
1569
|
+
return hash >>> 0;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function inferRouteMode(
|
|
1573
|
+
parts: RouteParts,
|
|
1574
|
+
usesRequest: boolean,
|
|
1575
|
+
config?: RouteSegmentConfig,
|
|
1576
|
+
): RouteMode {
|
|
1577
|
+
if (config?.dynamic === 'force-dynamic') return 'dynamic';
|
|
1578
|
+
if (config?.revalidate === 0) return 'dynamic';
|
|
1579
|
+
if (config?.fetchCache === 'force-no-store') return 'dynamic';
|
|
1580
|
+
if (config?.dynamic === 'force-static') return 'static';
|
|
1581
|
+
if (config?.dynamic === 'error') return 'static';
|
|
1582
|
+
if (usesRequest) return 'dynamic';
|
|
1583
|
+
if (parts.params.length > 0 || parts.catchAll) return 'dynamic';
|
|
1584
|
+
return 'static';
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
interface ParsedSegmentConfig {
|
|
1588
|
+
dynamic?: RouteSegmentConfig['dynamic'];
|
|
1589
|
+
revalidate?: number | false;
|
|
1590
|
+
fetchCache?: RouteSegmentConfig['fetchCache'];
|
|
1591
|
+
dynamicParams?: boolean;
|
|
1592
|
+
runtime?: string;
|
|
1593
|
+
prefetch?: RouteSegmentConfig['prefetch'];
|
|
1594
|
+
unstableInstant?: boolean;
|
|
1595
|
+
/** Literal but invalid `export const revalidate` value (build error in Next). */
|
|
1596
|
+
invalidRevalidate?: string;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function parseSegmentConfig(source: string): ParsedSegmentConfig {
|
|
1600
|
+
const searchable = stripComments(source);
|
|
1601
|
+
const parsed: ParsedSegmentConfig = {};
|
|
1602
|
+
const dynamicMatch =
|
|
1603
|
+
/^\s*export\s+const\s+dynamic\s*=\s*['"](auto|force-dynamic|error|force-static)['"]/m.exec(
|
|
1604
|
+
searchable,
|
|
1605
|
+
);
|
|
1606
|
+
if (dynamicMatch) parsed.dynamic = dynamicMatch[1] as RouteSegmentConfig['dynamic'];
|
|
1607
|
+
const revalidateMatch = /^\s*export\s+const\s+revalidate\s*=\s*(\d+|false)\s*;?\s*$/m.exec(
|
|
1608
|
+
searchable,
|
|
1609
|
+
);
|
|
1610
|
+
if (revalidateMatch) {
|
|
1611
|
+
parsed.revalidate = revalidateMatch[1] === 'false' ? false : Number(revalidateMatch[1]);
|
|
1612
|
+
} else {
|
|
1613
|
+
// Invalid literal values (strings, negatives) are a build error in Next.
|
|
1614
|
+
const invalid = /^\s*export\s+const\s+revalidate\s*=\s*(['"]([^'"]*)['"]|-\d+)/m.exec(
|
|
1615
|
+
searchable,
|
|
1616
|
+
);
|
|
1617
|
+
if (invalid) parsed.invalidRevalidate = invalid[2] ?? invalid[1];
|
|
1618
|
+
}
|
|
1619
|
+
const fetchCacheMatch =
|
|
1620
|
+
/^\s*export\s+const\s+fetchCache\s*=\s*['"](auto|default-cache|only-cache|force-cache|force-no-store|default-no-store|only-no-store)['"]/m.exec(
|
|
1621
|
+
searchable,
|
|
1622
|
+
);
|
|
1623
|
+
if (fetchCacheMatch) parsed.fetchCache = fetchCacheMatch[1] as RouteSegmentConfig['fetchCache'];
|
|
1624
|
+
const dynamicParamsMatch = /^\s*export\s+const\s+dynamicParams\s*=\s*(true|false)/m.exec(
|
|
1625
|
+
searchable,
|
|
1626
|
+
);
|
|
1627
|
+
if (dynamicParamsMatch) parsed.dynamicParams = dynamicParamsMatch[1] === 'true';
|
|
1628
|
+
const runtimeMatch = /^\s*export\s+const\s+runtime\s*=\s*['"](edge|nodejs)['"]/m.exec(searchable);
|
|
1629
|
+
if (runtimeMatch) parsed.runtime = runtimeMatch[1];
|
|
1630
|
+
const prefetchMatch =
|
|
1631
|
+
/^\s*export\s+const\s+prefetch\s*=\s*['"](allow-runtime|partial|unstable_eager)['"]/m.exec(
|
|
1632
|
+
searchable,
|
|
1633
|
+
);
|
|
1634
|
+
if (prefetchMatch) parsed.prefetch = prefetchMatch[1] as RouteSegmentConfig['prefetch'];
|
|
1635
|
+
// `unstable_instant = true | false | {…samples}` — an object literal opts in
|
|
1636
|
+
// like `true` (its samples refine the runtime-prefetch render, they don't
|
|
1637
|
+
// gate it).
|
|
1638
|
+
const instantMatch = /^\s*export\s+const\s+unstable_instant\s*=\s*(true|false|\{)/m.exec(
|
|
1639
|
+
searchable,
|
|
1640
|
+
);
|
|
1641
|
+
if (instantMatch) parsed.unstableInstant = instantMatch[1] !== 'false';
|
|
1642
|
+
return parsed;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* Merge segment config exports across the route's own file and its layout
|
|
1647
|
+
* chain. `entries` are ordered leaf-first (page/handler first, then layouts
|
|
1648
|
+
* leaf->root): the leaf-most declaration of a field wins, except
|
|
1649
|
+
* `force-dynamic` (any segment forces the whole route) and `revalidate`
|
|
1650
|
+
* (the lowest declared number wins, Next-style).
|
|
1651
|
+
*
|
|
1652
|
+
* `dynamicParams = false` is resolved per param: it governs the params that
|
|
1653
|
+
* appear in the declaring file's own directory prefix, taking the leaf-most
|
|
1654
|
+
* declaration for each param.
|
|
1655
|
+
*/
|
|
1656
|
+
function segmentConfigFromEntries(
|
|
1657
|
+
appPath: string,
|
|
1658
|
+
entries: { file: string; source: string }[],
|
|
1659
|
+
parts: RouteParts,
|
|
1660
|
+
): RouteSegmentConfig | undefined {
|
|
1661
|
+
const config: RouteSegmentConfig = {};
|
|
1662
|
+
let lowestRevalidate: number | undefined;
|
|
1663
|
+
let sawRevalidateFalse = false;
|
|
1664
|
+
const dynamicParamsByName = new Map<string, boolean>();
|
|
1665
|
+
const allParams = [...parts.params, ...(parts.catchAll ? [parts.catchAll] : [])];
|
|
1666
|
+
|
|
1667
|
+
for (const entry of entries) {
|
|
1668
|
+
const parsed = parseSegmentConfig(entry.source);
|
|
1669
|
+
if (parsed.invalidRevalidate !== undefined) {
|
|
1670
|
+
throw new Error(
|
|
1671
|
+
`Invalid revalidate value "${parsed.invalidRevalidate}" on "${parts.route || '/'}", must be a non-negative number or false`,
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
if (parsed.dynamic) {
|
|
1675
|
+
if (parsed.dynamic === 'force-dynamic') config.dynamic = 'force-dynamic';
|
|
1676
|
+
else if (!config.dynamic) config.dynamic = parsed.dynamic;
|
|
1677
|
+
}
|
|
1678
|
+
if (parsed.revalidate !== undefined) {
|
|
1679
|
+
if (parsed.revalidate === false) sawRevalidateFalse = true;
|
|
1680
|
+
else {
|
|
1681
|
+
lowestRevalidate =
|
|
1682
|
+
lowestRevalidate === undefined
|
|
1683
|
+
? parsed.revalidate
|
|
1684
|
+
: Math.min(lowestRevalidate, parsed.revalidate);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
if (parsed.fetchCache && !config.fetchCache) config.fetchCache = parsed.fetchCache;
|
|
1688
|
+
if (parsed.runtime && !config.runtime) config.runtime = parsed.runtime;
|
|
1689
|
+
if (parsed.prefetch && !config.prefetch) config.prefetch = parsed.prefetch;
|
|
1690
|
+
// Leaf-most declaration wins (entries are page-first): a page's explicit
|
|
1691
|
+
// `unstable_instant = false` overrides a layout's opt-in.
|
|
1692
|
+
if (parsed.unstableInstant !== undefined && config.unstableInstant === undefined) {
|
|
1693
|
+
config.unstableInstant = parsed.unstableInstant;
|
|
1694
|
+
}
|
|
1695
|
+
if (parsed.dynamicParams !== undefined) {
|
|
1696
|
+
const param = paramOfOwnSegment(appPath, entry.file, allParams);
|
|
1697
|
+
if (param !== undefined && !dynamicParamsByName.has(param)) {
|
|
1698
|
+
dynamicParamsByName.set(param, parsed.dynamicParams);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
if (lowestRevalidate !== undefined) config.revalidate = lowestRevalidate;
|
|
1704
|
+
else if (sawRevalidateFalse) config.revalidate = false;
|
|
1705
|
+
const governed = allParams.filter(param => dynamicParamsByName.get(param) === false);
|
|
1706
|
+
if (governed.length > 0) config.dynamicParamsFalse = governed;
|
|
1707
|
+
|
|
1708
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* The route param introduced by the file's own directory segment, if any. A `dynamicParams` declaration
|
|
1713
|
+
* governs that segment only - it does not cascade to child segments (a layout at `[locale]/` governs
|
|
1714
|
+
* `locale` but not a `[slug]` below it).
|
|
1715
|
+
*/
|
|
1716
|
+
function paramOfOwnSegment(appPath: string, file: string, params: string[]) {
|
|
1717
|
+
const segments = toPosixPath(path.relative(appPath, path.dirname(file))).split('/');
|
|
1718
|
+
const own = segments[segments.length - 1];
|
|
1719
|
+
return params.find(
|
|
1720
|
+
param => own === `[${param}]` || own === `[...${param}]` || own === `[[...${param}]]`,
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// The parse is memoized per source, so the thousands of directive checks a
|
|
1725
|
+
// scan makes cost one parse per file.
|
|
1726
|
+
function hasUseClientDirective(file: string, source: string) {
|
|
1727
|
+
return scanFacts(file, source).useClient;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function isClientModule(context: ScanContext, file: string) {
|
|
1731
|
+
const key = path.resolve(file);
|
|
1732
|
+
let client = context.useClient.get(key);
|
|
1733
|
+
if (client === undefined) {
|
|
1734
|
+
client = hasUseClientDirective(file, readSource(context, file));
|
|
1735
|
+
context.useClient.set(key, client);
|
|
1736
|
+
}
|
|
1737
|
+
return client;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
function collectClientReferences(context: ScanContext, entryFiles: string[]) {
|
|
1741
|
+
const references = new Map<string, ClientReference>();
|
|
1742
|
+
const visited = new Set<string>();
|
|
1743
|
+
|
|
1744
|
+
for (const file of entryFiles) {
|
|
1745
|
+
// A convention module may be a bare re-export of a client module
|
|
1746
|
+
// (`export { default } from './client-layout'`): its own source carries no
|
|
1747
|
+
// directive, but its default export IS a client component. Register the
|
|
1748
|
+
// reference under the ENTRY file so the renderer mounts the convention as
|
|
1749
|
+
// an island (the client bundle resolves the re-export chain naturally).
|
|
1750
|
+
if (fileExists(context, file)) {
|
|
1751
|
+
if (!isClientModule(context, file) && defaultReexportsClientModule(context, file)) {
|
|
1752
|
+
addClientReference(references, clientReference(file, 'default'));
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
collectFromFile(context, file, references, visited);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
const collected = [...references.values()];
|
|
1759
|
+
// Webpack eagerly initializes side-effect-only client modules (a bare `import './client-only'`) ONLY once
|
|
1760
|
+
// at least one client reference actually renders. A route whose sole client references are
|
|
1761
|
+
// side-effect-only executes nothing on the client, so drop them - otherwise their side effects would
|
|
1762
|
+
// wrongly run.
|
|
1763
|
+
const hasRenderedReference = collected.some(reference => !reference.sideEffect);
|
|
1764
|
+
const filtered = hasRenderedReference
|
|
1765
|
+
? collected
|
|
1766
|
+
: collected.filter(reference => !reference.sideEffect);
|
|
1767
|
+
|
|
1768
|
+
return filtered.sort((a, b) => a.id.localeCompare(b.id));
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* Whether `file`'s DEFAULT export is re-exported (possibly through a chain of
|
|
1773
|
+
* directive-less modules) from a `'use client'` module. Only the
|
|
1774
|
+
* `export { ... default ... } from` form participates: importing a client
|
|
1775
|
+
* component and rendering it is the ordinary island case, but re-exporting one
|
|
1776
|
+
* as your own default makes THIS module's default a client component.
|
|
1777
|
+
*/
|
|
1778
|
+
function defaultReexportsClientModule(
|
|
1779
|
+
context: ScanContext,
|
|
1780
|
+
file: string,
|
|
1781
|
+
visited = new Set<string>(),
|
|
1782
|
+
): boolean {
|
|
1783
|
+
const key = path.resolve(file);
|
|
1784
|
+
if (visited.has(key)) return false;
|
|
1785
|
+
visited.add(key);
|
|
1786
|
+
const source = readSource(context, file);
|
|
1787
|
+
const specifiers: string[] = [];
|
|
1788
|
+
for (const edge of scanFacts(file, source).imports) {
|
|
1789
|
+
// `export *` never carries a default, so only the named form counts.
|
|
1790
|
+
if (edge.reexport && edge.exports.includes('default')) specifiers.push(edge.specifier);
|
|
1791
|
+
}
|
|
1792
|
+
// `import X from 'spec'; export default X` and the namespace form
|
|
1793
|
+
// `import * as NS from 'spec'; export default NS.default` (the pages-compat
|
|
1794
|
+
// materializer emits the latter) re-export a default just the same.
|
|
1795
|
+
const defaultExpr = /export\s+default\s+([A-Za-z_$][\w$]*)(\s*\.\s*default)?\s*;?/.exec(source);
|
|
1796
|
+
if (defaultExpr) {
|
|
1797
|
+
const binding = defaultExpr[1]!;
|
|
1798
|
+
const namespaced = Boolean(defaultExpr[2]);
|
|
1799
|
+
const importBinding = namespaced
|
|
1800
|
+
? new RegExp(`import\\s*\\*\\s*as\\s+${binding}\\s+from\\s+['"]([^'"]+)['"]`).exec(source)
|
|
1801
|
+
: new RegExp(`import\\s+${binding}\\s+from\\s+['"]([^'"]+)['"]`).exec(source);
|
|
1802
|
+
if (importBinding?.[1]) specifiers.push(importBinding[1]);
|
|
1803
|
+
}
|
|
1804
|
+
for (const specifier of specifiers) {
|
|
1805
|
+
const resolved = resolveModuleEdge(context.root, file, specifier);
|
|
1806
|
+
if (!resolved || !fileExists(context, resolved)) continue;
|
|
1807
|
+
if (isClientModule(context, resolved)) return true;
|
|
1808
|
+
if (defaultReexportsClientModule(context, resolved, visited)) return true;
|
|
1809
|
+
}
|
|
1810
|
+
return false;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function collectFromFile(
|
|
1814
|
+
context: ScanContext,
|
|
1815
|
+
file: string,
|
|
1816
|
+
references: Map<string, ClientReference>,
|
|
1817
|
+
visited: Set<string>,
|
|
1818
|
+
) {
|
|
1819
|
+
if (visited.has(file) || !fileExists(context, file)) return;
|
|
1820
|
+
visited.add(file);
|
|
1821
|
+
|
|
1822
|
+
if (isClientModule(context, file)) {
|
|
1823
|
+
addClientReference(references, clientReference(file, 'default'));
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
for (const imported of moduleEdges(context, file)) {
|
|
1827
|
+
if (isClientModule(context, imported.file)) {
|
|
1828
|
+
// `export * from './client'` re-exports the target's named exports (never
|
|
1829
|
+
// its default), so each of them is a client reference of this module.
|
|
1830
|
+
if (imported.star) {
|
|
1831
|
+
const importedSource = readSource(context, imported.file);
|
|
1832
|
+
for (const exportName of scanFacts(imported.file, importedSource).exportNames) {
|
|
1833
|
+
if (exportName !== 'default') {
|
|
1834
|
+
addClientReference(references, clientReference(imported.file, exportName));
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
if (imported.exports.includes('*')) {
|
|
1840
|
+
throw new Error(
|
|
1841
|
+
`${file} namespace-imports Client Component module ${imported.file}. Import the client component by default or named export instead.`,
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
// A bare side-effect import binds no exports, but the module still belongs in the client bundle:
|
|
1845
|
+
// webpack eagerly initializes every client reference module once any of them renders, so its top-level
|
|
1846
|
+
// side effects must run. Register it as a side-effect-only reference - never SSR'd, never mounted, just
|
|
1847
|
+
// bundled and executed.
|
|
1848
|
+
if (imported.exports.length === 0) {
|
|
1849
|
+
addClientReference(references, {
|
|
1850
|
+
...clientReference(imported.file, '*side-effect*'),
|
|
1851
|
+
sideEffect: true,
|
|
1852
|
+
});
|
|
1853
|
+
continue;
|
|
1854
|
+
}
|
|
1855
|
+
for (const exportName of imported.exports) {
|
|
1856
|
+
addClientReference(references, {
|
|
1857
|
+
...clientReference(imported.file, exportName),
|
|
1858
|
+
dynamic: imported.dynamic,
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
if (imported.dynamic?.load === 'visible') {
|
|
1865
|
+
console.warn(
|
|
1866
|
+
`${file} uses dynamic({ load: 'visible' }) for ${imported.file}, but the target is a Server Component. It will render on the server instead of loading on visibility.`,
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
collectFromFile(context, imported.file, references, visited);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
function addClientReference(references: Map<string, ClientReference>, reference: ClientReference) {
|
|
1875
|
+
const existing = references.get(reference.id);
|
|
1876
|
+
if (!existing) {
|
|
1877
|
+
references.set(reference.id, reference);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
if (!reference.dynamic) references.set(reference.id, reference);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// CSS cascade order across a route's entry files: outer->inner layouts, then
|
|
1885
|
+
// templates, then the page itself, then boundary/slot files. `entryFiles` is
|
|
1886
|
+
// built page-first for module/client collection; CSS needs the reverse nesting
|
|
1887
|
+
// so a layout's stylesheet is emitted before (and thus loses specificity ties
|
|
1888
|
+
// to) the page's.
|
|
1889
|
+
function cssEntryOrder(options: {
|
|
1890
|
+
routeFile?: string;
|
|
1891
|
+
layoutFiles: string[];
|
|
1892
|
+
templateFiles: string[];
|
|
1893
|
+
specialFiles: string[];
|
|
1894
|
+
slotFiles: string[];
|
|
1895
|
+
leadFiles?: string[];
|
|
1896
|
+
}) {
|
|
1897
|
+
return [
|
|
1898
|
+
...(options.leadFiles ?? []),
|
|
1899
|
+
...options.layoutFiles,
|
|
1900
|
+
...options.templateFiles,
|
|
1901
|
+
...(options.routeFile ? [options.routeFile] : []),
|
|
1902
|
+
...options.specialFiles,
|
|
1903
|
+
...options.slotFiles,
|
|
1904
|
+
];
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function collectCssImports(
|
|
1908
|
+
context: ScanContext,
|
|
1909
|
+
entryFiles: string[],
|
|
1910
|
+
options: { route?: boolean } = {},
|
|
1911
|
+
) {
|
|
1912
|
+
const imports = new Set<string>();
|
|
1913
|
+
const visited = new Set<string>();
|
|
1914
|
+
|
|
1915
|
+
for (const file of entryFiles) {
|
|
1916
|
+
collectCssFromFile(context, file, imports, visited);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// Preserve source import order (a Set keeps insertion order): the emitted
|
|
1920
|
+
// stylesheet's cascade depends on it — layouts outer->inner, then page, then
|
|
1921
|
+
// components in import order. Sorting alphabetically would scramble the
|
|
1922
|
+
// cascade and flip which rule wins.
|
|
1923
|
+
if (!options.route || !getCssExtensions().deferRootNotFoundCss()) return [...imports];
|
|
1924
|
+
|
|
1925
|
+
const rootNotFound = entryFiles.find(
|
|
1926
|
+
file => path.dirname(file) === context.appPath && /^not-found\.[^.]+$/.test(path.basename(file)),
|
|
1927
|
+
);
|
|
1928
|
+
if (!rootNotFound) return [...imports];
|
|
1929
|
+
|
|
1930
|
+
const rootNotFoundImports = new Set<string>();
|
|
1931
|
+
collectCssFromFile(context, rootNotFound, rootNotFoundImports, new Set());
|
|
1932
|
+
if (rootNotFoundImports.size === 0) return [...imports];
|
|
1933
|
+
|
|
1934
|
+
const matchedImports = new Set<string>();
|
|
1935
|
+
for (const file of entryFiles) {
|
|
1936
|
+
if (file === rootNotFound) continue;
|
|
1937
|
+
collectCssFromFile(context, file, matchedImports, new Set());
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
return [...imports].filter(file => !rootNotFoundImports.has(file) || matchedImports.has(file));
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function collectSourceFiles(context: ScanContext, entryFiles: string[]) {
|
|
1944
|
+
const files = new Set<string>();
|
|
1945
|
+
const visited = new Set<string>();
|
|
1946
|
+
|
|
1947
|
+
for (const file of entryFiles) {
|
|
1948
|
+
collectSourceFile(context, file, files, visited);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
return [...files].sort();
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function collectSourceFile(
|
|
1955
|
+
context: ScanContext,
|
|
1956
|
+
file: string,
|
|
1957
|
+
files: Set<string>,
|
|
1958
|
+
visited: Set<string>,
|
|
1959
|
+
) {
|
|
1960
|
+
if (visited.has(file) || !fileExists(context, file)) return;
|
|
1961
|
+
visited.add(file);
|
|
1962
|
+
files.add(file);
|
|
1963
|
+
if (isAssetFile(file)) return;
|
|
1964
|
+
|
|
1965
|
+
for (const imported of moduleEdges(context, file)) {
|
|
1966
|
+
collectSourceFile(context, imported.file, files, visited);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function collectCssFromFile(
|
|
1971
|
+
context: ScanContext,
|
|
1972
|
+
file: string,
|
|
1973
|
+
imports: Set<string>,
|
|
1974
|
+
visited: Set<string>,
|
|
1975
|
+
) {
|
|
1976
|
+
if (visited.has(file) || !fileExists(context, file)) return;
|
|
1977
|
+
visited.add(file);
|
|
1978
|
+
|
|
1979
|
+
if (isCssFile(file)) {
|
|
1980
|
+
if (!context.globalCssImports().has(path.resolve(file))) imports.add(file);
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
const clientFile = isClientModule(context, file);
|
|
1985
|
+
for (const imported of moduleEdges(context, file)) {
|
|
1986
|
+
if (isCssFile(imported.file)) {
|
|
1987
|
+
if (!context.globalCssImports().has(path.resolve(imported.file))) imports.add(imported.file);
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// CSS behind a non-SSR island boundary ships with the island
|
|
1992
|
+
// (assets/<reference-id>.css) instead of the route chunk, so it loads
|
|
1993
|
+
// when the component does. Boundaries only exist in server files:
|
|
1994
|
+
// dynamic() inside a client subtree mounts without the island runtime.
|
|
1995
|
+
if (!clientFile && (isLazyClientEdge(context, imported))) continue;
|
|
1996
|
+
|
|
1997
|
+
collectCssFromFile(context, imported.file, imports, visited);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function isLazyClientEdge(context: ScanContext, edge: ModuleEdge) {
|
|
2002
|
+
if (!edge.dynamic || ssrClientReference({ dynamic: edge.dynamic })) return false;
|
|
2003
|
+
return isClientModule(context, edge.file);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
function attachLazyReferenceCss(context: ScanContext, references: ClientReference[]) {
|
|
2007
|
+
for (const reference of references) {
|
|
2008
|
+
if (ssrClientReference(reference)) continue;
|
|
2009
|
+
const css = collectCssImports(context, [reference.file]);
|
|
2010
|
+
if (css.length > 0) reference.cssImports = css;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/**
|
|
2015
|
+
* Entry source for FACT scans (segment config, revalidation-API usage, request
|
|
2016
|
+
* usage, generateStaticParams), with the sources of relative re-export targets
|
|
2017
|
+
* appended. Next evaluates the module, so `export const revalidate = 0` behind
|
|
2018
|
+
* an `export * from './route'` (the pages-compat materializer shims every
|
|
2019
|
+
* hybrid-app convention this way) still configures the route; text-only entry
|
|
2020
|
+
* scans would miss it and, e.g., prerender a revalidateTag route handler.
|
|
2021
|
+
* Relative specifiers only: bare imports never carry segment config.
|
|
2022
|
+
*/
|
|
2023
|
+
function readScanSource(
|
|
2024
|
+
context: ScanContext,
|
|
2025
|
+
file: string,
|
|
2026
|
+
visited = new Set<string>(),
|
|
2027
|
+
): string {
|
|
2028
|
+
const key = path.resolve(file);
|
|
2029
|
+
if (visited.has(key)) return '';
|
|
2030
|
+
visited.add(key);
|
|
2031
|
+
const source = readSource(context, file);
|
|
2032
|
+
const parts = [source];
|
|
2033
|
+
for (const edge of scanFacts(file, source).imports) {
|
|
2034
|
+
if (!edge.reexport || !edge.specifier.startsWith('.')) continue;
|
|
2035
|
+
const resolved = resolveModuleEdge(context.root, file, edge.specifier);
|
|
2036
|
+
if (!resolved || !existsSync(resolved)) continue;
|
|
2037
|
+
parts.push(readScanSource(context, resolved, visited));
|
|
2038
|
+
}
|
|
2039
|
+
return parts.join('\n');
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function readSource(context: ScanContext, file: string) {
|
|
2043
|
+
const key = path.resolve(file);
|
|
2044
|
+
let source = context.sources.get(key);
|
|
2045
|
+
if (source === undefined) {
|
|
2046
|
+
// Raw text comes from the build-scoped cache (a no-op outside a build), so
|
|
2047
|
+
// the client loader and action discovery don't re-read what this walk reads.
|
|
2048
|
+
source = rewriteLiteralDynamicCalls(readSourceSync(key), file);
|
|
2049
|
+
context.sources.set(key, source);
|
|
2050
|
+
}
|
|
2051
|
+
return source;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// True when any of the route's source files imports a Link component
|
|
2055
|
+
// (next/link in compat, or the core @wular/pnext/link). Matches the module
|
|
2056
|
+
// specifier so it survives aliasing/renamed imports of the default export.
|
|
2057
|
+
const linkSpecifiers = new Set(['next/link', '@wular/pnext/link']);
|
|
2058
|
+
|
|
2059
|
+
// Every module specifier through which a route's graph can reach a soft
|
|
2060
|
+
// navigation: Link, the client router hooks, and compat's navigation surface.
|
|
2061
|
+
// A route whose whole closure imports none of these can never start a soft
|
|
2062
|
+
// navigation, so its entry ships no router at all (client/entry.ts).
|
|
2063
|
+
const navigationSpecifiers = new Set([
|
|
2064
|
+
...linkSpecifiers,
|
|
2065
|
+
'next/form',
|
|
2066
|
+
'next/navigation',
|
|
2067
|
+
'next/router',
|
|
2068
|
+
'@wular/pnext/navigation',
|
|
2069
|
+
'@wular/pnext/navigation/client',
|
|
2070
|
+
]);
|
|
2071
|
+
|
|
2072
|
+
// The root `@wular/pnext` barrel also re-exports navigation: a NAMED import of
|
|
2073
|
+
// one of these counts, anything else (types, cache, config helpers) does not.
|
|
2074
|
+
// A bare/namespace/default import does not name what it reaches, so it counts —
|
|
2075
|
+
// the fact has to over-approximate, never under-approximate.
|
|
2076
|
+
const rootNavigationExports = new Set([
|
|
2077
|
+
'*',
|
|
2078
|
+
'default',
|
|
2079
|
+
'Link',
|
|
2080
|
+
'permanentRedirect',
|
|
2081
|
+
'redirect',
|
|
2082
|
+
'useLinkStatus',
|
|
2083
|
+
'useParams',
|
|
2084
|
+
'usePathname',
|
|
2085
|
+
'useRoute',
|
|
2086
|
+
'useRouter',
|
|
2087
|
+
'useSearchParams',
|
|
2088
|
+
]);
|
|
2089
|
+
|
|
2090
|
+
function importReachesNavigation(edge: { specifier: string; exports: string[] }) {
|
|
2091
|
+
if (navigationSpecifiers.has(edge.specifier)) return true;
|
|
2092
|
+
if (edge.specifier !== '@wular/pnext') return false;
|
|
2093
|
+
return edge.exports.length === 0 || edge.exports.some(name => rootNavigationExports.has(name));
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function routeUsesLinkComponent(context: ScanContext, files: string[]) {
|
|
2097
|
+
for (const file of files) {
|
|
2098
|
+
if (!fileExists(context, file) || isAssetFile(file)) continue;
|
|
2099
|
+
const key = path.resolve(file);
|
|
2100
|
+
let uses = context.usesLink.get(key);
|
|
2101
|
+
if (uses === undefined) {
|
|
2102
|
+
const facts = scanFacts(file, readSource(context, file));
|
|
2103
|
+
uses = facts.imports.some(edge => linkSpecifiers.has(edge.specifier));
|
|
2104
|
+
context.usesLink.set(key, uses);
|
|
2105
|
+
}
|
|
2106
|
+
if (uses) return true;
|
|
2107
|
+
}
|
|
2108
|
+
return false;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/** True when any file in the route's closure can reach a soft navigation. */
|
|
2112
|
+
function routeUsesNavigation(context: ScanContext, files: string[]) {
|
|
2113
|
+
for (const file of files) {
|
|
2114
|
+
if (!fileExists(context, file) || isAssetFile(file)) continue;
|
|
2115
|
+
const key = path.resolve(file);
|
|
2116
|
+
let uses = context.usesNavigation.get(key);
|
|
2117
|
+
if (uses === undefined) {
|
|
2118
|
+
const facts = scanFacts(file, readSource(context, file));
|
|
2119
|
+
uses = facts.imports.some(importReachesNavigation);
|
|
2120
|
+
context.usesNavigation.set(key, uses);
|
|
2121
|
+
}
|
|
2122
|
+
if (uses) return true;
|
|
2123
|
+
}
|
|
2124
|
+
return false;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function routeClientEntryReasons(context: ScanContext, files: string[]): ClientEntryReason[] {
|
|
2128
|
+
const reasons: ClientEntryReason[] = [];
|
|
2129
|
+
for (const file of files) {
|
|
2130
|
+
if (!fileExists(context, file) || isAssetFile(file)) continue;
|
|
2131
|
+
const key = path.resolve(file);
|
|
2132
|
+
let fileReasons = context.entryReasons.get(key);
|
|
2133
|
+
if (fileReasons === undefined) {
|
|
2134
|
+
fileReasons = sourceClientEntryReasons(readSource(context, file));
|
|
2135
|
+
context.entryReasons.set(key, fileReasons);
|
|
2136
|
+
}
|
|
2137
|
+
reasons.push(...fileReasons);
|
|
2138
|
+
}
|
|
2139
|
+
return reasons;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
function usesRequestImportDataForFiles(context: ScanContext, files: string[]) {
|
|
2143
|
+
for (const file of files) {
|
|
2144
|
+
const key = path.resolve(file);
|
|
2145
|
+
let usesRequestImport = context.requestImportUsage.get(key);
|
|
2146
|
+
if (usesRequestImport === undefined) {
|
|
2147
|
+
usesRequestImport = sourceUsesRegisteredRequestApi(readSource(context, file));
|
|
2148
|
+
context.requestImportUsage.set(key, usesRequestImport);
|
|
2149
|
+
}
|
|
2150
|
+
if (usesRequestImport) return true;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
return false;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function routeDependencyClassification(
|
|
2157
|
+
context: ScanContext,
|
|
2158
|
+
kind: 'page' | 'handler',
|
|
2159
|
+
files: string[],
|
|
2160
|
+
) {
|
|
2161
|
+
const sources = files
|
|
2162
|
+
.filter(file => !isAssetFile(file))
|
|
2163
|
+
.map(file => ({ file, source: readSource(context, file) }));
|
|
2164
|
+
return classifyRouteDependencies({ kind, files: sources });
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
function moduleEdges(context: ScanContext, file: string) {
|
|
2168
|
+
const key = path.resolve(file);
|
|
2169
|
+
let edges = context.edges.get(key);
|
|
2170
|
+
if (!edges) {
|
|
2171
|
+
edges = moduleEdgesFromSource(context.root, file, readSource(context, file));
|
|
2172
|
+
context.edges.set(key, edges);
|
|
2173
|
+
}
|
|
2174
|
+
return edges;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// Compat pins framework specifiers (`next/form`, ...) to its own absolute files, and the bundler's
|
|
2178
|
+
// importAliasPlugin gives those aliases precedence over every other resolver. The scanner's module-edge walk
|
|
2179
|
+
// must mirror that order - alias FIRST, generic resolution second: inside a workspace that ships a real
|
|
2180
|
+
// `next` package, plain resolveImport finds the real package's directive-less re-export stub and the
|
|
2181
|
+
// 'use client' boundary of the compat module is silently missed, so the route never gets its client
|
|
2182
|
+
// reference or entry.
|
|
2183
|
+
function resolveModuleEdge(root: string, file: string, specifier: string) {
|
|
2184
|
+
return (
|
|
2185
|
+
resolveModuleAlias(specifier) ??
|
|
2186
|
+
resolveImport(root, file, specifier) ??
|
|
2187
|
+
getBundlerExtensions().resolveRouteDependency(root, file, specifier) ??
|
|
2188
|
+
resolveScopedClientBoundaryPackage(root, file, specifier) ??
|
|
2189
|
+
getCssExtensions().resolveCssDependency(root, file, specifier)
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
const scopedClientBoundaryPackages = new Set(['@next/third-parties']);
|
|
2194
|
+
|
|
2195
|
+
function resolveScopedClientBoundaryPackage(root: string, file: string, specifier: string) {
|
|
2196
|
+
if (!scopedClientBoundaryPackages.has(packageNameOfSpecifier(specifier) ?? '')) return undefined;
|
|
2197
|
+
return resolvePackageSpecifier(root, file, specifier, ['import', 'default']);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function moduleEdgesFromSource(root: string, file: string, source: string) {
|
|
2201
|
+
const dynamicNames = pnextDynamicImportNames(source, file);
|
|
2202
|
+
assertSupportedDynamicCalls(file, source, dynamicNames);
|
|
2203
|
+
// Edges carry their byte offset so the final list follows true source order:
|
|
2204
|
+
// CSS injection order is a source-order property — a side-effect
|
|
2205
|
+
// `import './a.css'` written before a component import must be emitted
|
|
2206
|
+
// before that component's CSS.
|
|
2207
|
+
const ordered: (ModuleEdge & { index: number })[] = [];
|
|
2208
|
+
const facts = scanFacts(file, source);
|
|
2209
|
+
|
|
2210
|
+
for (const edge of facts.imports) {
|
|
2211
|
+
const resolved = resolveModuleEdge(root, file, edge.specifier);
|
|
2212
|
+
if (!resolved) continue;
|
|
2213
|
+
ordered.push({
|
|
2214
|
+
file: resolved,
|
|
2215
|
+
exports: [...edge.exports],
|
|
2216
|
+
index: edge.index,
|
|
2217
|
+
...(edge.star ? { star: true } : {}),
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
if (scopedClientBoundaryPackages.has(packageNameForFile(file) ?? '')) {
|
|
2222
|
+
for (const call of facts.requires) {
|
|
2223
|
+
const resolved = resolveModuleEdge(root, file, call.specifier);
|
|
2224
|
+
if (!resolved) continue;
|
|
2225
|
+
const exports = commonJsModuleHasDefaultExport(readFileSync(resolved, 'utf8'), resolved)
|
|
2226
|
+
? ['default']
|
|
2227
|
+
: [];
|
|
2228
|
+
ordered.push({ file: resolved, exports, index: call.index });
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
ordered.sort((a, b) => a.index - b.index);
|
|
2233
|
+
const imports: ModuleEdge[] = ordered.map(({ index: _index, ...edge }) => edge);
|
|
2234
|
+
|
|
2235
|
+
for (const imported of dynamicImportEdgesFromSource(root, file, source, dynamicNames))
|
|
2236
|
+
imports.push(imported);
|
|
2237
|
+
|
|
2238
|
+
return imports;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function assertSupportedDynamicCalls(file: string, source: string, dynamicNames: Set<string>) {
|
|
2242
|
+
for (const call of dynamicCallsFromSource(source, dynamicNames)) {
|
|
2243
|
+
if (!/\bimport\s*\(/.test(call.source)) {
|
|
2244
|
+
throw new Error(
|
|
2245
|
+
`${file} uses dynamic() without a literal import. PNext supports dynamic('./module') or dynamic(() => import('./module')).`,
|
|
2246
|
+
);
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
if (!/\bimport\s*\(\s*['"][^'"]+['"]\s*\)/.test(call.source)) {
|
|
2250
|
+
throw new Error(
|
|
2251
|
+
`${file} uses a non-literal dynamic import. PNext supports dynamic('./module') or dynamic(() => import('./module')).`,
|
|
2252
|
+
);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function dynamicImportEdgesFromSource(
|
|
2258
|
+
root: string,
|
|
2259
|
+
file: string,
|
|
2260
|
+
source: string,
|
|
2261
|
+
dynamicNames: Set<string>,
|
|
2262
|
+
) {
|
|
2263
|
+
const imports: { file: string; exports: string[]; dynamic: ClientDynamicReference }[] = [];
|
|
2264
|
+
if (dynamicNames.size === 0) return imports;
|
|
2265
|
+
|
|
2266
|
+
const optionObjects = dynamicOptionObjects(source);
|
|
2267
|
+
const dynamicPattern =
|
|
2268
|
+
/import\s*\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.then\s*\(\s*([A-Za-z_$][\w$]*)\s*=>\s*\2\.([A-Za-z_$][\w$]*)\s*\))?/g;
|
|
2269
|
+
let match: RegExpExecArray | null;
|
|
2270
|
+
|
|
2271
|
+
while ((match = dynamicPattern.exec(source))) {
|
|
2272
|
+
const [, specifier, _moduleName, exportName] = match;
|
|
2273
|
+
if (!specifier) continue;
|
|
2274
|
+
if (!isDynamicCallImport(source, match.index, dynamicNames)) continue;
|
|
2275
|
+
|
|
2276
|
+
const resolved = resolveModuleEdge(root, file, specifier);
|
|
2277
|
+
if (!resolved) continue;
|
|
2278
|
+
imports.push({
|
|
2279
|
+
file: resolved,
|
|
2280
|
+
exports: [exportName ?? 'default'],
|
|
2281
|
+
dynamic: dynamicOptionsForImport(source, dynamicPattern.lastIndex, optionObjects),
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
return imports;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
function dynamicOptionObjects(source: string) {
|
|
2289
|
+
const options = new Map<string, ClientDynamicReference>();
|
|
2290
|
+
const pattern = /const\s+([A-Za-z_$][\w$]*)\s*=\s*({[\s\S]*?})\s*(?:as\s+const\s*)?;/g;
|
|
2291
|
+
let match: RegExpExecArray | null;
|
|
2292
|
+
|
|
2293
|
+
while ((match = pattern.exec(source))) {
|
|
2294
|
+
const [, name, objectSource] = match;
|
|
2295
|
+
if (name && objectSource) options.set(name, dynamicOptionsFromSource(objectSource));
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
return options;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
function isDynamicCallImport(source: string, importIndex: number, dynamicNames: Set<string>) {
|
|
2302
|
+
const prefix = source.slice(Math.max(0, importIndex - 120), importIndex);
|
|
2303
|
+
const dynamicIndex = nextDynamicCallIndexFromEnd(prefix, dynamicNames);
|
|
2304
|
+
if (dynamicIndex === -1) return false;
|
|
2305
|
+
return !/[;\n]\s*$/.test(prefix.slice(dynamicIndex));
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function nextDynamicCallIndexFromEnd(source: string, dynamicNames: Set<string>) {
|
|
2309
|
+
let next = -1;
|
|
2310
|
+
for (const name of dynamicNames) {
|
|
2311
|
+
const index = source.lastIndexOf(name);
|
|
2312
|
+
if (index !== -1 && index > next) next = index;
|
|
2313
|
+
}
|
|
2314
|
+
return next;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
function dynamicOptionsForImport(
|
|
2318
|
+
source: string,
|
|
2319
|
+
endIndex: number,
|
|
2320
|
+
optionObjects: Map<string, ClientDynamicReference>,
|
|
2321
|
+
) {
|
|
2322
|
+
const tail = source.slice(endIndex, endIndex + 300);
|
|
2323
|
+
const inlineMatch = /^\s*,\s*({[\s\S]*?})\s*,?\s*\)/.exec(tail);
|
|
2324
|
+
if (inlineMatch?.[1]) return dynamicOptionsFromSource(inlineMatch[1]);
|
|
2325
|
+
|
|
2326
|
+
const identifierMatch = /^\s*,\s*([A-Za-z_$][\w$]*)\s*,?\s*\)/.exec(tail);
|
|
2327
|
+
if (identifierMatch?.[1]) return optionObjects.get(identifierMatch[1]) ?? {};
|
|
2328
|
+
|
|
2329
|
+
return {};
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
function dynamicOptionsFromSource(source?: string): ClientDynamicReference {
|
|
2333
|
+
if (!source) return {};
|
|
2334
|
+
return {
|
|
2335
|
+
...dynamicLoadFromSource(source),
|
|
2336
|
+
...dynamicRootMarginFromSource(source),
|
|
2337
|
+
...dynamicSsrFromSource(source),
|
|
2338
|
+
...dynamicThresholdFromSource(source),
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
function dynamicLoadFromSource(source: string): ClientDynamicReference {
|
|
2343
|
+
const match = /\bload\s*:\s*['"](render|visible)['"]/.exec(source);
|
|
2344
|
+
return match?.[1] === 'render' || match?.[1] === 'visible' ? { load: match[1] } : {};
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function dynamicRootMarginFromSource(source: string): ClientDynamicReference {
|
|
2348
|
+
const match = /\brootMargin\s*:\s*['"]([^'"]+)['"]/.exec(source);
|
|
2349
|
+
return match?.[1] ? { rootMargin: match[1] } : {};
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
function dynamicSsrFromSource(source: string): ClientDynamicReference {
|
|
2353
|
+
const match = /\bssr\s*:\s*(true|false)\b/.exec(source);
|
|
2354
|
+
return match?.[1] ? { ssr: match[1] === 'true' } : {};
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function dynamicThresholdFromSource(source: string): ClientDynamicReference {
|
|
2358
|
+
const numberMatch = /\bthreshold\s*:\s*(-?\d+(?:\.\d+)?)/.exec(source);
|
|
2359
|
+
if (numberMatch?.[1]) return { threshold: Number(numberMatch[1]) };
|
|
2360
|
+
|
|
2361
|
+
const arrayMatch = /\bthreshold\s*:\s*\[([^\]]*)\]/.exec(source);
|
|
2362
|
+
if (!arrayMatch?.[1]) return {};
|
|
2363
|
+
const threshold = arrayMatch[1]
|
|
2364
|
+
.split(',')
|
|
2365
|
+
.map(value => Number(value.trim()))
|
|
2366
|
+
.filter(Number.isFinite);
|
|
2367
|
+
return threshold.length > 0 ? { threshold } : {};
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function isCssFile(file: string) {
|
|
2371
|
+
return file.endsWith('.css') || getCssExtensions().extraCssExtensions().some(ext => file.endsWith(ext));
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function isAssetFile(file: string) {
|
|
2375
|
+
return (
|
|
2376
|
+
/\.(css|svg|png|jpe?g|gif|webp|ico|woff2?)$/.test(file) ||
|
|
2377
|
+
getCssExtensions().extraCssExtensions().some(ext => file.endsWith(ext))
|
|
2378
|
+
);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
function exportsExperimentalPpr(sources: string[]) {
|
|
2382
|
+
return sources.some(source =>
|
|
2383
|
+
/\bexport\s+const\s+experimental_ppr\s*=\s*true\b/.test(stripComments(source)),
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
function usesRequestData(source: string) {
|
|
2388
|
+
const searchable = stripJsxText(stripCommentsAndStrings(source));
|
|
2389
|
+
// Core knows only its own generic request-data markers; the next/* import
|
|
2390
|
+
// detectors (next/headers, next/navigation hooks, next/server connection())
|
|
2391
|
+
// are registered by compat via the usageDetection seam.
|
|
2392
|
+
return /\b(?:request|searchParams)\b/.test(searchable) || sourceUsesRegisteredRequestApi(source);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
/**
|
|
2396
|
+
* Blank out JSX text nodes: rendered prose is not code, so `<h1>No searchParams used</h1>` must not read as a
|
|
2397
|
+
* request-data access and force the page dynamic. Only plain-text runs qualify - a run between two angle
|
|
2398
|
+
* brackets that spans lines or carries code punctuation (an arrow body, a comparison, an interpolation) is
|
|
2399
|
+
* left untouched so a real usage between JSX tags is still seen.
|
|
2400
|
+
*/
|
|
2401
|
+
function stripJsxText(source: string) {
|
|
2402
|
+
return source.replace(/>([^<>(){}=;\n]*)</g, (_match, text: string) => `>${' '.repeat(text.length)}<`);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
/**
|
|
2406
|
+
* The dynamic data API a route statically references, named the way Next names
|
|
2407
|
+
* it in its "couldn't be rendered statically" build error (backticks included,
|
|
2408
|
+
* exactly as it appears after "it used "). Used only to enforce
|
|
2409
|
+
* `dynamic = 'error'`: such a route must render statically, so any dynamic data
|
|
2410
|
+
* access is a hard build failure. Detection is a source scan (Next decides at
|
|
2411
|
+
* render time by which API is hit first); for the single-API fixtures this is
|
|
2412
|
+
* exact, and server data APIs are checked before `searchParams` so a page that
|
|
2413
|
+
* reads both reports the server API. Returns undefined when no dynamic API is
|
|
2414
|
+
* referenced (the route can prerender).
|
|
2415
|
+
*/
|
|
2416
|
+
function dynamicApiLabel(sources: string[], kind: 'page' | 'handler'): string | undefined {
|
|
2417
|
+
const joined = sources.map(stripCommentsAndStrings).join('\n');
|
|
2418
|
+
if (/\bconnection\s*\(/.test(joined)) return '`connection()`';
|
|
2419
|
+
if (/\bcookies\s*\(/.test(joined)) return '`cookies()`';
|
|
2420
|
+
if (/\bheaders\s*\(/.test(joined)) return '`headers()`';
|
|
2421
|
+
if (/\bdraftMode\s*\(/.test(joined)) return '`draftMode()`';
|
|
2422
|
+
if (kind === 'handler') {
|
|
2423
|
+
if (/\.formData\b/.test(joined)) return '`request.formData`';
|
|
2424
|
+
if (/\bnextUrl\b/.test(joined)) return '`nextUrl.toString`';
|
|
2425
|
+
if (/\brequest\.url\b|\breq\.url\b/.test(joined)) return '`request.url`';
|
|
2426
|
+
} else if (/\bsearchParams\b/.test(joined)) {
|
|
2427
|
+
return '`await searchParams`, `searchParams.then`, or similar';
|
|
2428
|
+
}
|
|
2429
|
+
return undefined;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
function maxDurationFromSources(sources: string[]) {
|
|
2433
|
+
const durations = sources
|
|
2434
|
+
.map(source => /\bexport\s+const\s+maxDuration\s*=\s*(\d+)\b/.exec(source)?.[1])
|
|
2435
|
+
.filter((value): value is string => Boolean(value))
|
|
2436
|
+
.map(value => Number(value));
|
|
2437
|
+
return durations.length > 0 ? Math.max(...durations) : undefined;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// On-demand revalidation calls make a handler dynamic in Next: caching the
|
|
2441
|
+
// response would freeze the side effect at build time.
|
|
2442
|
+
function usesRevalidationApis(source: string) {
|
|
2443
|
+
return /\b(?:revalidatePath|revalidateTag|updateTag|expirePath|expireTag|unstable_expirePath|unstable_expireTag)\s*\(/.test(
|
|
2444
|
+
stripCommentsAndStrings(source),
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** Sentinel for a destructured first parameter (no identifier to track). */
|
|
2449
|
+
const DESTRUCTURED_PARAM = '<destructured>';
|
|
2450
|
+
|
|
2451
|
+
function routeHandlerUsesRequest(source: string) {
|
|
2452
|
+
const withoutComments = stripComments(source);
|
|
2453
|
+
const searchable = stripCommentsAndStrings(source);
|
|
2454
|
+
// Compat's usageDetection (next/headers import, etc.) marks a handler dynamic.
|
|
2455
|
+
if (sourceUsesRegisteredRequestApi(source)) return true;
|
|
2456
|
+
for (const method of routeHandlerMethods) {
|
|
2457
|
+
for (const params of exportedFunctionParams(withoutComments, method)) {
|
|
2458
|
+
const name = firstParameterName(params);
|
|
2459
|
+
// A destructured request parameter (`GET({ nextUrl })`) reads the request
|
|
2460
|
+
// in the signature itself — there is no binding to count references for.
|
|
2461
|
+
if (name === DESTRUCTURED_PARAM) return true;
|
|
2462
|
+
if (name && referencesName(searchable, name) > 1) return true;
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
return false;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
function exportedFunctionParams(source: string, method: string) {
|
|
2469
|
+
const params: string[] = [];
|
|
2470
|
+
const functionPattern = new RegExp(
|
|
2471
|
+
`\\bexport\\s+(?:async\\s+)?function\\s+${method}\\s*\\(([^)]*)\\)`,
|
|
2472
|
+
'g',
|
|
2473
|
+
);
|
|
2474
|
+
let match: RegExpExecArray | null;
|
|
2475
|
+
while ((match = functionPattern.exec(source))) {
|
|
2476
|
+
params.push(match[1] ?? '');
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
const arrowPattern = new RegExp(
|
|
2480
|
+
`\\bexport\\s+const\\s+${method}\\s*(?::[^=]+)?=\\s*(?:async\\s*)?(?:\\(([^)]*)\\)|([A-Za-z_$][\\w$]*))\\s*=>`,
|
|
2481
|
+
'g',
|
|
2482
|
+
);
|
|
2483
|
+
while ((match = arrowPattern.exec(source))) {
|
|
2484
|
+
params.push(match[1] ?? match[2] ?? '');
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
return params;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
function firstParameterName(params: string) {
|
|
2491
|
+
const first = params.split(',')[0]?.trim();
|
|
2492
|
+
if (!first) return undefined;
|
|
2493
|
+
if (first.startsWith('{') || first.startsWith('[')) return DESTRUCTURED_PARAM;
|
|
2494
|
+
const name =
|
|
2495
|
+
/^\.{3}\s*([A-Za-z_$][\w$]*)/.exec(first)?.[1] ?? /^([A-Za-z_$][\w$]*)/.exec(first)?.[1];
|
|
2496
|
+
return name;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
function referencesName(source: string, name: string) {
|
|
2500
|
+
const pattern = new RegExp(`\\b${escapeRegex(name)}\\b`, 'g');
|
|
2501
|
+
return [...source.matchAll(pattern)].length;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function stripCommentsAndStrings(source: string) {
|
|
2505
|
+
return stripComments(source).replace(/(['"`])(?:\\[\s\S]|(?!\1)[^\\])*\1/g, '');
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
function stripComments(source: string) {
|
|
2509
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function rootFromAppPath(appPath: string) {
|
|
2513
|
+
const normalized = toPosixPath(appPath);
|
|
2514
|
+
return normalized.endsWith('/src/app')
|
|
2515
|
+
? path.dirname(path.dirname(appPath))
|
|
2516
|
+
: path.dirname(appPath);
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
function clientReference(file: string, exportName: string): ClientReference {
|
|
2520
|
+
return {
|
|
2521
|
+
id: `c-${clientReferenceId(file, exportName)}`,
|
|
2522
|
+
file,
|
|
2523
|
+
exportName,
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
function routeId(route: string) {
|
|
2528
|
+
if (route === '/') return 'index';
|
|
2529
|
+
return route
|
|
2530
|
+
.split('/')
|
|
2531
|
+
.filter(Boolean)
|
|
2532
|
+
.map(segment => {
|
|
2533
|
+
if (segment.startsWith(':') && segment.endsWith('*'))
|
|
2534
|
+
return `catchall-${sanitizeIdPart(segment.slice(1, -1))}`;
|
|
2535
|
+
if (segment.startsWith(':')) return `param-${sanitizeIdPart(segment.slice(1))}`;
|
|
2536
|
+
return sanitizeIdPart(segment);
|
|
2537
|
+
})
|
|
2538
|
+
.join('-');
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
function sanitizeIdPart(value: string) {
|
|
2542
|
+
return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'route';
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
function routeRank(route: RouteManifestEntry) {
|
|
2546
|
+
// Segment-wise specificity, Next-style: compare left to right with
|
|
2547
|
+
// literal < param < catch-all at each level, so /parallel/foo (from a slot)
|
|
2548
|
+
// beats /[lang]/foo while /parallel/[...all] still beats /[lang]/foo.
|
|
2549
|
+
// Synthetic entries only lose ties against a real route with the same shape.
|
|
2550
|
+
const spec = route.route
|
|
2551
|
+
.split('/')
|
|
2552
|
+
.filter(Boolean)
|
|
2553
|
+
.map(segment => (segment.startsWith(':') ? (segment.endsWith('*') ? '2' : '1') : '0'))
|
|
2554
|
+
.join('');
|
|
2555
|
+
return `${spec}:${route.synthetic ? 1 : 0}:${route.route}`;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
function escapeRegex(value: string) {
|
|
2559
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2560
|
+
}
|