fix(javascript): bind Next page admission to source context

This commit is contained in:
morluto committed 2026-10-24 02:50:17 +08:00
1 parent c20b707680
commit 5ee5e49cb4
13 files changed
+532 -53

No files matched your search

+94 -11
View File
@@ -1,3 +1,42 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
const packageIdentitySchema = z.strictObject({
path: z.string().min(1),
sha256: digestSchema,
container_sha256: digestSchema,
});
export const nextAppSourceContextSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("app-directory"),
directory: z.string().min(1),
package: packageIdentitySchema,
}),
z.strictObject({
kind: z.literal("compiled-directory"),
directory: z.string().min(1),
package: packageIdentitySchema,
}),
z.strictObject({
kind: z.literal("outside-app-directory"),
package: packageIdentitySchema,
}),
z.strictObject({
kind: z.literal("unknown"),
reason: z.string(),
package: packageIdentitySchema.nullable(),
}),
]);
export type NextAppSourceContext = z.infer<typeof nextAppSourceContextSchema>;
export type NextAppRouteContext =
| { readonly kind: "source-path"; readonly source: NextAppSourceContext }
| { readonly kind: "script-url" }
| {
readonly kind: "source-literal";
readonly runtime: "next" | "unknown";
}
| { readonly kind: "source-reference" };
/** URL route syntax, independent of filesystem path normalization. */
export const routePath = (
value: string,
@@ -19,27 +58,71 @@ export type NextAppRoutePath =
/** Classify a page filename without treating missing routing context as absence. */
export const classifyNextAppRoutePath = (
raw: string,
context: NextAppRouteContext,
): NextAppRoutePath | undefined => {
if (raw.includes("\\")) return;
if (raw.includes("\\") || context.kind === "source-reference") return;
let value = raw;
if (raw.includes("://")) {
if (
context.kind === "script-url" ||
(context.kind === "source-literal" && raw.includes("://"))
) {
try {
value = new URL(raw).pathname;
} catch {
return;
}
}
// A deployment prefix may itself contain an `app` segment. Only the chunk
// path following Next's asset boundary can describe an App Router module.
const assetRoot = value.indexOf("/_next/");
if (assetRoot >= 0) value = value.slice(assetRoot + "/_next/".length);
const match =
/(?:^|\/)app\/(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)(?:[?#].*)?$/u.exec(
value,
// Ownership selects the routing root before page-segment conversion.
let relative: string | undefined;
let chunk = false;
if (context.kind === "source-path") {
if (context.source.kind === "outside-app-directory") return;
if (
context.source.kind === "app-directory" ||
context.source.kind === "compiled-directory"
) {
const prefix = `${context.source.directory}/`;
if (!value.startsWith(prefix)) return;
relative = value.slice(prefix.length);
chunk = context.source.kind === "compiled-directory";
}
} else if (context.kind === "script-url" || context.runtime === "next") {
const boundary = "/_next/static/chunks/app/";
const assetRoot = value.indexOf(boundary);
const localChunk = ["static/chunks/app/", ".next/static/chunks/app/"].find(
(prefix) => value.startsWith(prefix),
);
if (!match) return;
if (match[2] !== undefined && !/(?:^|\/)static\/chunks\/app\//u.test(value))
if (assetRoot >= 0) {
relative = value.slice(assetRoot + boundary.length);
chunk = true;
} else if (context.kind === "source-literal" && localChunk !== undefined) {
relative = value.slice(localChunk.length);
chunk = true;
} else if (context.kind === "source-literal") {
const prefix = value.startsWith("src/app/") ? "src/app/" : "app/";
if (value.startsWith(prefix)) relative = value.slice(prefix.length);
}
}
if (relative === undefined) {
if (
/(?:^|\/)app\/(?:.*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u.test(
value,
)
)
return {
kind: "unknown",
reason:
context.kind === "source-path" && context.source.kind === "unknown"
? context.source.reason
: "The page-shaped path has no established Next App Router root or runtime context.",
};
return;
}
const match =
/^(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)(?:[?#].*)?$/u.exec(
relative,
);
if (match === null || (match[2] !== undefined && !chunk)) return;
const sourceSegments = (match[1] ?? "").split("/");
// A private folder excludes the whole page, including intercepted ancestors.
// Keep filesystem segment spelling: only Next's explicit %5F escape is