diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index a29b0584..bb9535e8 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -12,11 +12,10 @@ * execution). Vite's parseAst() is NOT used because it doesn't handle * TypeScript syntax. * - * Limitation: without running the build, we cannot detect dynamic API usage - * (headers(), cookies(), connection(), etc.) that implicitly forces a route - * dynamic. Routes without explicit `export const dynamic` or - * `export const revalidate` are classified as "unknown" rather than "static" - * to avoid false confidence. + * Dynamic API imports (headers(), cookies(), connection(), unstable_noStore()) + * are detected heuristically and classified as "ssr". Routes without explicit + * config or detectable dynamic API usage are classified as "unknown" rather + * than "static" to avoid false confidence. */ import fs from "node:fs"; @@ -40,6 +39,10 @@ export type RouteRow = { * Used by `formatBuildReport` to add a note in the legend. */ prerendered?: boolean; + /** Pre-render status from the prerender phase, if available. */ + prerenderStatus?: "rendered" | "skipped" | "error"; + /** For dynamic routes: the concrete URLs that were pre-rendered. */ + prerenderPaths?: string[]; }; // ─── Regex-based export detection ──────────────────────────────────────────── @@ -98,6 +101,47 @@ export function extractExportConstNumber(code: string, name: string): number | n return m[1] === "Infinity" ? Infinity : parseFloat(m[1]); } +// ─── Dynamic API detection (module-level compiled regexes) ─────────────────── + +// Anchored to line start (^) to avoid matching commented-out imports. +// Uses multiline flag so ^ matches each line in multi-line source. +// Negative lookbehind (? or revalidate: false or revalidate: Infinity @@ -701,9 +745,15 @@ export function classifyAppRoute( // Fall back to isDynamic flag (dynamic URL segments without explicit config) if (isDynamic) return { type: "ssr" }; - // No explicit config and no dynamic URL segments — we can't confirm static - // without running the build (dynamic API calls like headers() are invisible - // to static analysis). Report as unknown rather than falsely claiming static. + // Check for imports of dynamic APIs — these strongly suggest the route is + // dynamic even without explicit `export const dynamic` configuration. + // This improves on "unknown" but remains a heuristic: the import could be + // conditional or unused at render time. + if (detectsDynamicApiUsage(code)) return { type: "ssr" }; + + // No explicit config, no dynamic URL segments, and no detected dynamic API + // imports — we can't confirm static without running the build. + // Report as unknown rather than falsely claiming static. return { type: "unknown" }; } @@ -726,17 +776,33 @@ export function buildReportRows(options: { }): RouteRow[] { const rows: RouteRow[] = []; - // Build a set of routes that were confirmed rendered by speculative prerender. - const renderedRoutes = new Set(); + // Build maps from prerender results for route enrichment. + const prerenderStatusMap = new Map(); + const prerenderPathsMap = new Map(); if (options.prerenderResult) { for (const r of options.prerenderResult.routes) { - if (r.status === "rendered") renderedRoutes.add(r.route); + // For rendered routes with a concrete path (dynamic routes expanded to URLs), + // collect all paths under the route pattern. + if (r.status === "rendered") { + prerenderStatusMap.set(r.route, "rendered"); + if (r.path) { + const paths = prerenderPathsMap.get(r.route) ?? []; + paths.push(r.path); + prerenderPathsMap.set(r.route, paths); + } + } else if (!prerenderStatusMap.has(r.route)) { + // Only set skipped/error if not already rendered (a dynamic route may + // have some rendered paths and some errors). + prerenderStatusMap.set(r.route, r.status); + } } } for (const route of options.pageRoutes ?? []) { const { type, revalidate } = classifyPagesRoute(route.filePath); - rows.push({ pattern: route.pattern, type, revalidate }); + const prerenderStatus = prerenderStatusMap.get(route.pattern); + const prerenderPaths = prerenderPathsMap.get(route.pattern); + rows.push({ pattern: route.pattern, type, revalidate, prerenderStatus, prerenderPaths }); } for (const route of options.apiRoutes ?? []) { @@ -745,11 +811,19 @@ export function buildReportRows(options: { for (const route of options.appRoutes ?? []) { const { type, revalidate } = classifyAppRoute(route.pagePath, route.routePath, route.isDynamic); - if (type === "unknown" && renderedRoutes.has(route.pattern)) { + const prerenderStatus = prerenderStatusMap.get(route.pattern); + const prerenderPaths = prerenderPathsMap.get(route.pattern); + if (type === "unknown" && prerenderStatus === "rendered") { // Speculative prerender confirmed this route is static. - rows.push({ pattern: route.pattern, type: "static", prerendered: true }); + rows.push({ + pattern: route.pattern, + type: "static", + prerendered: true, + prerenderStatus, + prerenderPaths, + }); } else { - rows.push({ pattern: route.pattern, type, revalidate }); + rows.push({ pattern: route.pattern, type, revalidate, prerenderStatus, prerenderPaths }); } } @@ -761,7 +835,7 @@ export function buildReportRows(options: { // ─── Formatting ─────────────────────────────────────────────────────────────── -const SYMBOLS: Record = { +export const SYMBOLS: Record = { static: "○", isr: "◐", ssr: "ƒ", @@ -804,8 +878,20 @@ export function formatBuildReport(rows: RouteRow[], routerLabel = "app"): string const sym = SYMBOLS[row.type]; const suffix = row.type === "isr" && row.revalidate !== undefined ? ` (${row.revalidate}s)` : ""; + // Prerender annotation + let prerenderSuffix = ""; + if (row.prerenderStatus === "rendered") { + const pathCount = row.prerenderPaths?.length; + prerenderSuffix = pathCount + ? ` [prerendered: ${pathCount} path${pathCount !== 1 ? "s" : ""}]` + : " [prerendered]"; + } else if (row.prerenderStatus === "skipped") { + prerenderSuffix = " [skipped]"; + } else if (row.prerenderStatus === "error") { + prerenderSuffix = " [error]"; + } const padding = " ".repeat(maxPatternLen - row.pattern.length); - lines.push(` ${corner} ${sym} ${row.pattern}${padding}${suffix}`); + lines.push(` ${corner} ${sym} ${row.pattern}${padding}${suffix}${prerenderSuffix}`); }); lines.push(""); @@ -819,11 +905,9 @@ export function formatBuildReport(rows: RouteRow[], routerLabel = "app"): string // Explanatory note — only shown when unknown routes are present if (usedTypes.includes("unknown")) { lines.push(""); - lines.push(" ? Some routes could not be classified. vinext currently uses static analysis"); - lines.push( - " and cannot detect dynamic API usage (headers(), cookies(), etc.) at build time.", - ); - lines.push(" Automatic classification will be improved in a future release."); + lines.push(" ? Some routes could not be fully classified by static analysis. Routes that"); + lines.push(" import dynamic APIs (headers(), cookies(), etc.) are detected as dynamic,"); + lines.push(" but other dynamic patterns may not be caught without running the build."); } // Speculative-render note — shown when any routes were confirmed static by prerender @@ -836,6 +920,20 @@ export function formatBuildReport(rows: RouteRow[], routerLabel = "app"): string lines.push(" succeeded without dynamic API usage)."); } + // Prerender summary — shown when any routes have prerender status + const hasAnyPrerender = rows.some((r) => r.prerenderStatus); + if (hasAnyPrerender) { + const renderedCount = rows.filter((r) => r.prerenderStatus === "rendered").length; + const skippedCount = rows.filter((r) => r.prerenderStatus === "skipped").length; + const errorCount = rows.filter((r) => r.prerenderStatus === "error").length; + lines.push(""); + const summaryParts: string[] = []; + if (renderedCount > 0) summaryParts.push(`${renderedCount} prerendered`); + if (skippedCount > 0) summaryParts.push(`${skippedCount} skipped`); + if (errorCount > 0) summaryParts.push(`${errorCount} failed`); + lines.push(` Prerender: ${summaryParts.join(", ")}`); + } + return lines.join("\n"); } diff --git a/packages/vinext/src/build/run-prerender.ts b/packages/vinext/src/build/run-prerender.ts index 23298d39..880c094a 100644 --- a/packages/vinext/src/build/run-prerender.ts +++ b/packages/vinext/src/build/run-prerender.ts @@ -31,7 +31,7 @@ import { import { loadNextConfig, resolveNextConfig } from "../config/next-config.js"; import { pagesRouter, apiRouter } from "../routing/pages-router.js"; import { appRouter } from "../routing/app-router.js"; -import { findDir } from "./report.js"; +import { findDir, classifyAppRoute, classifyPagesRoute, SYMBOLS } from "./report.js"; import { startProdServer } from "../server/prod-server.js"; // ─── Progress UI ────────────────────────────────────────────────────────────── @@ -41,20 +41,47 @@ import { startProdServer } from "../server/prod-server.js"; * * Writes a single updating line to stderr using \r so it doesn't interleave * with Vite's stdout output. Automatically clears on finish(). + * + * Shows phase labels, real-time status breakdown, elapsed time, and render rate. */ export class PrerenderProgress { private isTTY = process.stderr.isTTY; private lastLineLen = 0; + private startTime = Date.now(); + private phase = ""; + private rendered = 0; + private skipped = 0; + private errors = 0; + + setPhase(label: string): void { + this.phase = label; + } + + update( + completed: number, + total: number, + route: string, + status?: "rendered" | "skipped" | "error", + ): void { + if (status === "rendered") this.rendered++; + else if (status === "skipped") this.skipped++; + else if (status === "error") this.errors++; - update(completed: number, total: number, route: string): void { if (!this.isTTY) return; - const pct = total > 0 ? Math.floor((completed / total) * 100) : 0; - const bar = `[${"█".repeat(Math.floor(pct / 5))}${" ".repeat(20 - Math.floor(pct / 5))}]`; - // Truncate long route names to keep the line under ~80 chars - const maxRoute = 40; + const pct = total > 0 ? Math.min(Math.floor((completed / total) * 100), 100) : 0; + const filled = Math.floor(pct / 5); + const bar = `[${"█".repeat(filled)}${"░".repeat(20 - filled)}]`; + const maxRoute = 30; const routeLabel = route.length > maxRoute ? "…" + route.slice(-(maxRoute - 1)) : route; - const line = `Prerendering routes... ${bar} ${String(completed).padStart(String(total).length)}/${total} ${routeLabel}`; - // Pad to overwrite previous line, then carriage-return (no newline) + const elapsed = (Date.now() - this.startTime) / 1000; + const rate = elapsed > 0 ? (completed / elapsed).toFixed(1) : "0.0"; + const phaseLabel = this.phase ? `${this.phase} ` : ""; + const statusParts: string[] = []; + if (this.rendered > 0) statusParts.push(`${this.rendered} rendered`); + if (this.skipped > 0) statusParts.push(`${this.skipped} skipped`); + if (this.errors > 0) statusParts.push(`${this.errors} error${this.errors !== 1 ? "s" : ""}`); + const statusStr = statusParts.length > 0 ? ` (${statusParts.join(", ")})` : ""; + const line = ` ${phaseLabel}${bar} ${String(completed).padStart(String(total).length)}/${total}${statusStr} ${rate} routes/s ${routeLabel}`; const padded = line.padEnd(this.lastLineLen); this.lastLineLen = line.length; process.stderr.write(`\r${padded}`); @@ -62,12 +89,74 @@ export class PrerenderProgress { finish(rendered: number, skipped: number, errors: number): void { if (this.isTTY) { - // Clear the progress line process.stderr.write(`\r${" ".repeat(this.lastLineLen)}\r`); } - const errorPart = errors > 0 ? `, ${errors} error${errors !== 1 ? "s" : ""}` : ""; - console.log(` Prerendered ${rendered} routes (${skipped} skipped${errorPart}).`); + const elapsed = (Date.now() - this.startTime) / 1000; + const total = rendered + skipped + errors; + const rate = elapsed > 0 ? (total / elapsed).toFixed(1) : "0.0"; + const parts: string[] = []; + if (rendered > 0) parts.push(`${rendered} rendered`); + if (skipped > 0) parts.push(`${skipped} skipped`); + if (errors > 0) parts.push(`${errors} error${errors !== 1 ? "s" : ""}`); + const breakdown = parts.length > 0 ? ` (${parts.join(", ")})` : ""; + const timeStr = elapsed >= 1 ? ` in ${elapsed.toFixed(1)}s` : ""; + const noun = total === 1 ? "route" : "routes"; + console.log(` Prerendered ${total} ${noun}${timeStr}${breakdown} — ${rate} routes/s`); + } +} + +// ─── Route filtering helpers ───────────────────────────────────────────────── + +/** + * Compile a glob pattern into a RegExp. + * Supports `*` (matches any characters except `/`) and `**` (matches anything including `/`). + */ +export function compileRouteGlob(pattern: string): RegExp { + // Use a Unicode placeholder that won't appear in route patterns to + // distinguish ** from * during the replacement chain. + const DOUBLE_STAR = "\uFFFF"; + const regexStr = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, DOUBLE_STAR) // placeholder for ** + .replace(/\*/g, "[^/]*") // * matches within segment + .replace(new RegExp(DOUBLE_STAR, "g"), ".*"); // ** matches across segments + return new RegExp(`^${regexStr}$`); +} + +/** + * Test if a route pattern matches a glob pattern. + * For repeated matching against the same globs, prefer `compileRouteGlob` + * to compile once and reuse the resulting RegExp. + */ +export function matchRouteGlob(route: string, pattern: string): boolean { + return compileRouteGlob(pattern).test(route); +} + +/** + * Create a filter function from include/exclude glob patterns. + * Compiles each pattern once upfront and returns a predicate. + */ +function createRouteFilter( + includeRoutes?: string[], + excludeRoutes?: string[], +): ((pattern: string) => boolean) | null { + if ( + (!includeRoutes || includeRoutes.length === 0) && + (!excludeRoutes || excludeRoutes.length === 0) + ) { + return null; } + const includeRegexes = includeRoutes?.map(compileRouteGlob); + const excludeRegexes = excludeRoutes?.map(compileRouteGlob); + return (pattern: string) => { + if (includeRegexes && includeRegexes.length > 0) { + if (!includeRegexes.some((re) => re.test(pattern))) return false; + } + if (excludeRegexes && excludeRegexes.length > 0) { + if (excludeRegexes.some((re) => re.test(pattern))) return false; + } + return true; + }; } // ─── Shared runner ──────────────────────────────────────────────────────────── @@ -93,6 +182,31 @@ export type RunPrerenderOptions = { * Intended for tests that build to a custom outDir. */ rscBundlePath?: string; + /** + * Only prerender routes matching these glob patterns. + * Uses `*` (single segment) and `**` (any depth). Matched against the + * route's pattern string (e.g. `/blog/*` matches route `/blog/:slug`). + * When set, only matching routes are rendered; others are skipped. + */ + includeRoutes?: string[]; + /** + * Skip routes matching these glob patterns. + * Uses `*` (single segment) and `**` (any depth). For example, + * `/api/**` excludes all API routes. Applied after `includeRoutes`. + */ + excludeRoutes?: string[]; + /** + * When true, scan and classify routes but do not actually render them. + * Prints a table of routes that would be prerendered and returns null. + * Useful for previewing what would be prerendered. + */ + dryRun?: boolean; + /** + * Progress callback for external consumers who want to track + * prerender progress programmatically (instead of the built-in TTY + * progress bar). + */ + onProgress?: import("./prerender.js").PrerenderProgressCallback; }; /** @@ -140,6 +254,59 @@ export async function runPrerender(options: RunPrerenderOptions): Promise routeFilter(r.pattern)); + for (const route of routes) { + const { type } = classifyAppRoute(route.pagePath, route.routePath, route.isDynamic); + const sym = SYMBOLS[type] ?? "?"; + const action = type === "api" || type === "ssr" ? "skip" : "render"; + console.log(` ${sym} ${route.pattern} → ${action}`); + if (action === "render") renderCount++; + else skipCount++; + } + } + + if (pagesDir) { + let [pageRoutes, apiRoutes_] = await Promise.all([ + pagesRouter(pagesDir, config.pageExtensions), + apiRouter(pagesDir, config.pageExtensions), + ]); + if (routeFilter) { + pageRoutes = pageRoutes.filter((r) => routeFilter(r.pattern)); + apiRoutes_ = apiRoutes_.filter((r) => routeFilter(r.pattern)); + } + + for (const route of pageRoutes) { + const { type } = classifyPagesRoute(route.filePath); + const sym = SYMBOLS[type] ?? "?"; + const action = type === "api" || type === "ssr" ? "skip" : "render"; + console.log(` ${sym} ${route.pattern} → ${action}`); + if (action === "render") renderCount++; + else skipCount++; + } + for (const route of apiRoutes_) { + console.log(` λ ${route.pattern} → skip`); + skipCount++; + } + } + + const skipPart = skipCount > 0 ? `, ${skipCount} skipped` : ""; + console.log(`\n ${renderCount} routes would be rendered${skipPart}`); + return null; + } + const allRoutes: PrerenderRouteResult[] = []; // Count total renderable URLs across both phases upfront so we can show a @@ -192,7 +359,10 @@ export async function runPrerender(options: RunPrerenderOptions): Promise routeFilter(r.pattern)); // We don't know the exact render-queue size until prerenderApp starts, so // use the progress callback's `total` to update our combined total on the @@ -208,13 +378,21 @@ export async function runPrerender(options: RunPrerenderOptions): Promise { + onProgress: (update) => { + const { total, route, status } = update; if (appTotal === 0) { appTotal = total; totalUrls += total; } completedUrls += 1; - progress.update(completedUrls, totalUrls, route); + progress.update(completedUrls, totalUrls, route, status); + try { + options.onProgress?.(update); + } catch (err: unknown) { + process.stderr.write( + `[vinext] onProgress callback error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } }, }); @@ -223,10 +401,16 @@ export async function runPrerender(options: RunPrerenderOptions): Promise routeFilter(r.pattern)); + apiRoutes = apiRoutes.filter((r) => routeFilter(r.pattern)); + } let pagesTotal = 0; const result = await prerenderPages({ @@ -245,13 +429,21 @@ export async function runPrerender(options: RunPrerenderOptions): Promise { + onProgress: (update) => { + const { total, route, status } = update; if (pagesTotal === 0) { pagesTotal = total; totalUrls += total; } completedUrls += 1; - progress.update(completedUrls, totalUrls, route); + progress.update(completedUrls, totalUrls, route, status); + try { + options.onProgress?.(update); + } catch (err: unknown) { + process.stderr.write( + `[vinext] onProgress callback error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } }, }); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 93d2ed60..548b6490 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3951,3 +3951,17 @@ export { parseStaticObjectLiteral as _parseStaticObjectLiteral }; export { _findBalancedObject, _findCallEnd }; export { stripServerExports as _stripServerExports }; export { asyncHooksStubPlugin as _asyncHooksStubPlugin }; + +// Public prerender API +export { + runPrerender, + PrerenderProgress, + compileRouteGlob, + matchRouteGlob, +} from "./build/run-prerender.js"; +export type { RunPrerenderOptions } from "./build/run-prerender.js"; +export type { + PrerenderResult, + PrerenderRouteResult, + PrerenderProgressCallback, +} from "./build/prerender.js"; diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index d1e63e32..3d521c5f 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -14,6 +14,7 @@ import { extractExportConstString, extractExportConstNumber, extractGetStaticPropsRevalidate, + detectsDynamicApiUsage, classifyPagesRoute, classifyAppRoute, buildReportRows, @@ -22,6 +23,7 @@ import { } from "../packages/vinext/src/build/report.js"; import { invalidateAppRouteCache } from "../packages/vinext/src/routing/app-router.js"; import { invalidateRouteCache } from "../packages/vinext/src/routing/pages-router.js"; +import { matchRouteGlob } from "../packages/vinext/src/build/run-prerender.js"; const FIXTURES_PAGES = path.resolve("tests/fixtures/pages-basic/pages"); const FIXTURES_APP = path.resolve("tests/fixtures/app-basic/app"); @@ -325,6 +327,113 @@ export { getStaticProps } from "./shared"; }); }); +// ─── detectsDynamicApiUsage ──────────────────────────────────────────────────── + +describe("detectsDynamicApiUsage", () => { + it("detects headers import from next/headers", () => { + const code = `import { headers } from "next/headers";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(true); + }); + + it("detects cookies import from next/headers", () => { + const code = `import { cookies } from 'next/headers';\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(true); + }); + + it("detects connection import from next/server", () => { + const code = `import { connection } from "next/server";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(true); + }); + + it("does not detect connection from next/headers (wrong module)", () => { + const code = `import { connection } from "next/headers";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(false); + }); + + it("detects multiple dynamic imports from next/headers", () => { + const code = `import { headers, cookies } from "next/headers";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(true); + }); + + it("detects unstable_noStore from next/cache", () => { + const code = `import { unstable_noStore } from "next/cache";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(true); + }); + + it("detects noStore from next/cache", () => { + const code = `import { noStore } from "next/cache";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(true); + }); + + it("detects draftMode from next/headers", () => { + const code = `import { draftMode } from "next/headers";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(true); + }); + + it("returns false when no dynamic APIs are imported", () => { + const code = `import { cache } from "next/cache";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(false); + }); + + it("returns false for unrelated imports", () => { + const code = `import { useState } from "react";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(false); + }); + + it("returns false for empty code", () => { + expect(detectsDynamicApiUsage("")).toBe(false); + }); + + // ── Edge cases ────────────────────────────────────────────────────────────── + + it("ignores commented-out imports", () => { + const code = `// import { headers } from "next/headers";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(false); + }); + + it("ignores block-commented imports", () => { + const code = `/* import { headers } from "next/headers"; */\nexport default function Page() {}`; + // Block comments don't start with `import` at line start, so the ^ anchor skips them + expect(detectsDynamicApiUsage(code)).toBe(false); + }); + + it("detects aliased imports (headers as h)", () => { + const code = `import { headers as h } from "next/headers";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(true); + }); + + it("detects multi-line imports", () => { + const code = `import {\n headers,\n cookies,\n} from "next/headers";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(true); + }); + + it("returns false for type-only imports (import type { ... })", () => { + const code = `import type { headers } from "next/headers";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(false); + }); + + it("returns false for inline type modifier (import { type cookies })", () => { + const code = `import { type cookies } from "next/headers";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(false); + }); + + it("detects value import alongside inline type modifier", () => { + // Mixed import: type-only + value — the value import should still trigger detection + const code = `import { type RequestCookies, cookies } from "next/headers";\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(true); + }); + + it("returns false for require() calls", () => { + const code = `const { headers } = require("next/headers");\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(false); + }); + + it("returns false for dynamic import() calls", () => { + const code = `const { headers } = await import("next/headers");\nexport default function Page() {}`; + expect(detectsDynamicApiUsage(code)).toBe(false); + }); +}); + // ─── classifyPagesRoute (integration — real fixture files) ──────────────────── describe("classifyPagesRoute", () => { @@ -411,6 +520,13 @@ describe("classifyAppRoute", () => { const pagePath = path.join(FIXTURES_APP, "revalidate-infinity-test", "page.tsx"); expect(classifyAppRoute(pagePath, null, false)).toEqual({ type: "static" }); }); + + it("classifies page importing dynamic APIs (headers/cookies) as ssr", () => { + // headers-test/page.tsx imports { headers, cookies } from "next/headers" + // — no explicit dynamic/revalidate config, but heuristic detects dynamic API usage + const pagePath = path.join(FIXTURES_APP, "headers-test", "page.tsx"); + expect(classifyAppRoute(pagePath, null, false)).toEqual({ type: "ssr" }); + }); }); // ─── buildReportRows ────────────────────────────────────────────────────────── @@ -580,8 +696,8 @@ describe("formatBuildReport", () => { ]; const out = formatBuildReport(rows); expect(out).toContain("? Unknown"); - expect(out).toContain("could not be classified"); - expect(out).toContain("future release"); + expect(out).toContain("could not be fully classified"); + expect(out).toContain("without running the build"); }); it("produces the full expected format for a mixed set of routes", () => { @@ -604,6 +720,305 @@ describe("formatBuildReport", () => { }); }); +// ─── formatBuildReport prerender annotations ───────────────────────────────── + +describe("formatBuildReport prerender annotations", () => { + it("shows [prerendered] for rendered routes", () => { + const rows = [{ pattern: "/", type: "static" as const, prerenderStatus: "rendered" as const }]; + const out = formatBuildReport(rows); + expect(out).toContain("[prerendered]"); + }); + + it("shows [prerendered: N paths] for dynamic routes with paths", () => { + const rows = [ + { + pattern: "/blog/:slug", + type: "isr" as const, + revalidate: 60, + prerenderStatus: "rendered" as const, + prerenderPaths: ["/blog/foo", "/blog/bar", "/blog/baz"], + }, + ]; + const out = formatBuildReport(rows); + expect(out).toContain("[prerendered: 3 paths]"); + }); + + it("shows singular [prerendered: 1 path] for single path", () => { + const rows = [ + { + pattern: "/blog/:slug", + type: "isr" as const, + revalidate: 60, + prerenderStatus: "rendered" as const, + prerenderPaths: ["/blog/only"], + }, + ]; + const out = formatBuildReport(rows); + expect(out).toContain("[prerendered: 1 path]"); + }); + + it("shows [skipped] for skipped routes", () => { + const rows = [ + { pattern: "/dashboard", type: "ssr" as const, prerenderStatus: "skipped" as const }, + ]; + const out = formatBuildReport(rows); + expect(out).toContain("[skipped]"); + }); + + it("shows [error] for error routes", () => { + const rows = [{ pattern: "/broken", type: "ssr" as const, prerenderStatus: "error" as const }]; + const out = formatBuildReport(rows); + expect(out).toContain("[error]"); + }); + + it("shows prerender summary when prerender data is present", () => { + const rows = [ + { pattern: "/", type: "static" as const, prerenderStatus: "rendered" as const }, + { pattern: "/about", type: "static" as const, prerenderStatus: "rendered" as const }, + { pattern: "/dashboard", type: "ssr" as const, prerenderStatus: "skipped" as const }, + ]; + const out = formatBuildReport(rows); + expect(out).toContain("Prerender: 2 prerendered, 1 skipped"); + }); + + it("does not show prerender summary when no prerender data", () => { + const rows = [{ pattern: "/", type: "static" as const }]; + const out = formatBuildReport(rows); + expect(out).not.toContain("Prerender:"); + }); +}); + +// ─── buildReportRows prerender enrichment ───────────────────────────────────── + +describe("buildReportRows prerender enrichment", () => { + // Helper to create a minimal AppRoute with all required fields + const makeAppRoute = (overrides: { + pattern: string; + pagePath: string | null; + isDynamic?: boolean; + params?: string[]; + }) => ({ + pattern: overrides.pattern, + patternParts: overrides.pattern.split("/").filter(Boolean), + pagePath: overrides.pagePath, + routePath: null, + isDynamic: overrides.isDynamic ?? false, + params: overrides.params ?? [], + layouts: [], + templates: [], + parallelSlots: [], + loadingPath: null, + errorPath: null, + layoutErrorPaths: [], + notFoundPath: null, + notFoundPaths: [], + forbiddenPath: null, + unauthorizedPath: null, + routeSegments: [], + layoutTreePositions: [], + }); + + it("populates prerenderStatus from prerenderResult", () => { + const appRoutes = [ + makeAppRoute({ + pattern: "/", + pagePath: path.join(FIXTURES_APP, "static-test", "page.tsx"), + }), + ]; + const prerenderResult = { + routes: [ + { + route: "/", + status: "rendered" as const, + outputFiles: ["index.html"], + revalidate: false as const, + router: "app" as const, + }, + ], + }; + const rows = buildReportRows({ appRoutes, prerenderResult }); + expect(rows[0].prerenderStatus).toBe("rendered"); + }); + + it("collects prerenderPaths for dynamic routes", () => { + const appRoutes = [ + makeAppRoute({ + pattern: "/blog/:slug", + pagePath: path.join(FIXTURES_APP, "blog", "[slug]", "page.tsx"), + isDynamic: true, + params: ["slug"], + }), + ]; + const prerenderResult = { + routes: [ + { + route: "/blog/:slug", + status: "rendered" as const, + outputFiles: ["blog/foo.html"], + revalidate: false as const, + path: "/blog/foo", + router: "app" as const, + }, + { + route: "/blog/:slug", + status: "rendered" as const, + outputFiles: ["blog/bar.html"], + revalidate: false as const, + path: "/blog/bar", + router: "app" as const, + }, + ], + }; + const rows = buildReportRows({ appRoutes, prerenderResult }); + expect(rows[0].prerenderPaths).toEqual(["/blog/foo", "/blog/bar"]); + }); + + it("upgrades unknown to static when speculatively rendered", () => { + const appRoutes = [ + makeAppRoute({ + pattern: "/mystery", + pagePath: "/nonexistent/page.tsx", + }), + ]; + const prerenderResult = { + routes: [ + { + route: "/mystery", + status: "rendered" as const, + outputFiles: ["mystery.html"], + revalidate: false as const, + router: "app" as const, + }, + ], + }; + const rows = buildReportRows({ appRoutes, prerenderResult }); + expect(rows[0].type).toBe("static"); + expect(rows[0].prerendered).toBe(true); + expect(rows[0].prerenderStatus).toBe("rendered"); + }); + + it("populates prerenderStatus=skipped for skipped routes", () => { + const appRoutes = [ + makeAppRoute({ + pattern: "/dashboard", + pagePath: path.join(FIXTURES_APP, "dynamic-test", "page.tsx"), + }), + ]; + const prerenderResult = { + routes: [{ route: "/dashboard", status: "skipped" as const, reason: "dynamic" as const }], + }; + const rows = buildReportRows({ appRoutes, prerenderResult }); + expect(rows[0].prerenderStatus).toBe("skipped"); + }); + + it("populates prerenderStatus=error for error routes", () => { + const appRoutes = [ + makeAppRoute({ + pattern: "/broken", + pagePath: path.join(FIXTURES_APP, "dynamic-test", "page.tsx"), + }), + ]; + const prerenderResult = { + routes: [{ route: "/broken", status: "error" as const, error: "render failed" }], + }; + const rows = buildReportRows({ appRoutes, prerenderResult }); + expect(rows[0].prerenderStatus).toBe("error"); + }); + + it("rendered status takes priority over error for same route", () => { + const appRoutes = [ + makeAppRoute({ + pattern: "/blog/:slug", + pagePath: path.join(FIXTURES_APP, "blog", "[slug]", "page.tsx"), + isDynamic: true, + params: ["slug"], + }), + ]; + const prerenderResult = { + routes: [ + { + route: "/blog/:slug", + status: "rendered" as const, + outputFiles: ["blog/ok.html"], + revalidate: false as const, + path: "/blog/ok", + router: "app" as const, + }, + { route: "/blog/:slug", status: "error" as const, error: "one path failed" }, + ], + }; + const rows = buildReportRows({ appRoutes, prerenderResult }); + expect(rows[0].prerenderStatus).toBe("rendered"); + }); +}); + +// ─── formatBuildReport speculative-render note ──────────────────────────────── + +describe("formatBuildReport speculative-render note", () => { + it("shows speculative prerender note when prerendered flag is set", () => { + const rows = [ + { + pattern: "/mystery", + type: "static" as const, + prerendered: true, + prerenderStatus: "rendered" as const, + }, + ]; + const out = formatBuildReport(rows); + expect(out).toContain("confirmed by speculative prerender"); + }); + + it("does not show speculative note when no prerendered routes", () => { + const rows = [{ pattern: "/", type: "static" as const, prerenderStatus: "rendered" as const }]; + const out = formatBuildReport(rows); + expect(out).not.toContain("confirmed by speculative prerender"); + }); +}); + +// ─── matchRouteGlob ─────────────────────────────────────────────────────────── + +describe("matchRouteGlob", () => { + it("matches exact paths", () => { + expect(matchRouteGlob("/about", "/about")).toBe(true); + expect(matchRouteGlob("/about", "/other")).toBe(false); + }); + + it("matches * within a single segment", () => { + expect(matchRouteGlob("/api/hello", "/api/*")).toBe(true); + expect(matchRouteGlob("/api/v1/hello", "/api/*")).toBe(false); + }); + + it("matches ** across segments", () => { + expect(matchRouteGlob("/api/v1/hello", "/api/**")).toBe(true); + expect(matchRouteGlob("/api/hello", "/api/**")).toBe(true); + }); + + it("matches root path", () => { + expect(matchRouteGlob("/", "/")).toBe(true); + expect(matchRouteGlob("/about", "/")).toBe(false); + }); + + it("handles special regex characters in patterns", () => { + expect(matchRouteGlob("/blog/(group)/page", "/blog/(group)/page")).toBe(true); + expect(matchRouteGlob("/blog/[slug]", "/blog/[slug]")).toBe(true); + }); + + it("matches route param patterns with *", () => { + expect(matchRouteGlob("/blog/:slug", "/blog/*")).toBe(true); + expect(matchRouteGlob("/blog/:slug/comments", "/blog/*/comments")).toBe(true); + }); + + it("escapes dot correctly (. should not match any character)", () => { + expect(matchRouteGlob("/files/data.json", "/files/*.json")).toBe(true); + expect(matchRouteGlob("/files/dataxjson", "/files/*.json")).toBe(false); + }); + + it("matches patterns with both * and **", () => { + expect(matchRouteGlob("/api/v1/users", "/api/**/users")).toBe(true); + expect(matchRouteGlob("/api/v1/v2/users", "/api/**/users")).toBe(true); + }); +}); + // ─── printBuildReport with pageExtensions ───────────────────────────────────── describe("printBuildReport respects pageExtensions", () => {