diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 76191ee4..7a3dff40 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -60,6 +60,7 @@ "@vercel/og": "catalog:", "magic-string": "catalog:", "rsc-html-stream": "catalog:", + "typescript": "catalog:", "vite-plugin-commonjs": "catalog:", "vite-tsconfig-paths": "catalog:" }, diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 4e4cfdfe..d618ab68 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -8,9 +8,9 @@ * ? Unknown — no explicit config; likely dynamic but not confirmed * λ API — API route handler * - * Classification uses regex-based static source analysis (no module - * execution). Vite's parseAst() is NOT used because it doesn't handle - * TypeScript syntax. + * Classification uses AST-based static source analysis (no module execution) + * via the TypeScript compiler API. 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 @@ -21,6 +21,7 @@ import fs from "node:fs"; import path from "node:path"; +import ts from "typescript"; import type { Route } from "../routing/pages-router.js"; import type { AppRoute } from "../routing/app-router.js"; import type { PrerenderResult } from "./prerender.js"; @@ -42,7 +43,45 @@ export interface RouteRow { prerendered?: boolean; } -// ─── Regex-based export detection ──────────────────────────────────────────── +// ─── AST-based export detection ────────────────────────────────────────────── + +type ResolvedExport = + | { + kind: "local"; + localName: string; + } + | { + kind: "reexport"; + }; + +interface ModuleAnalysis { + sourceFile: ts.SourceFile; + localBindings: Map; + exports: Map; +} + +interface ParsedSourceCandidate { + sourceFile: ts.SourceFile; + diagnosticCount: number; + scriptKind: ts.ScriptKind; +} + +type ResolvedFunctionLike = ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction; + +type GetStaticPropsResolution = + | { + kind: "absent"; + } + | { + kind: "reexport"; + } + | { + kind: "function"; + fn: ResolvedFunctionLike; + } + | { + kind: "unresolved"; + }; /** * Returns true if the source code contains a named export with the given name. @@ -51,20 +90,8 @@ export interface RouteRow { * export const foo = ... * export { foo } */ -export function hasNamedExport(code: string, name: string): boolean { - // Function / generator / async function declaration - const fnRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:async\\s+)?function\\s+${name}\\b`); - if (fnRe.test(code)) return true; - - // Variable declaration (const / let / var) - const varRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:const|let|var)\\s+${name}\\s*[=:]`); - if (varRe.test(code)) return true; - - // Re-export specifier: export { foo } or export { foo as bar } - const reRe = new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`); - if (reRe.test(code)) return true; - - return false; +export function hasNamedExport(code: string, name: string, filePath?: string): boolean { + return hasNamedExportFromAnalysis(getModuleAnalysis(code, filePath), name); } /** @@ -73,13 +100,26 @@ export function hasNamedExport(code: string, name: string): boolean { * export const dynamic: string = "force-dynamic" * Returns null if the export is absent or not a string literal. */ -export function extractExportConstString(code: string, name: string): string | null { - const re = new RegExp( - `^\\s*export\\s+const\\s+${name}\\s*(?::[^=]+)?\\s*=\\s*['"]([^'"]+)['"]`, - "m", - ); - const m = re.exec(code); - return m ? m[1] : null; +export function extractExportConstString( + code: string, + name: string, + filePath?: string, +): string | null { + return extractExportConstStringFromAnalysis(getModuleAnalysis(code, filePath), name); +} + +function extractExportConstStringFromAnalysis( + analysis: ModuleAnalysis, + name: string, +): string | null { + const initializer = resolveExportedConstExpression(analysis, name); + if (!initializer) return null; + + if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) { + return initializer.text; + } + + return null; } /** @@ -88,14 +128,19 @@ export function extractExportConstString(code: string, name: string): string | n * Handles optional TypeScript type annotations. * Returns null if the export is absent or not a number. */ -export function extractExportConstNumber(code: string, name: string): number | null { - const re = new RegExp( - `^\\s*export\\s+const\\s+${name}\\s*(?::[^=]+)?\\s*=\\s*(-?\\d+(?:\\.\\d+)?|Infinity)`, - "m", - ); - const m = re.exec(code); - if (!m) return null; - return m[1] === "Infinity" ? Infinity : parseFloat(m[1]); +export function extractExportConstNumber( + code: string, + name: string, + filePath?: string, +): number | null { + return extractExportConstNumberFromAnalysis(getModuleAnalysis(code, filePath), name); +} + +function extractExportConstNumberFromAnalysis( + analysis: ModuleAnalysis, + name: string, +): number | null { + return extractNumericLiteralValue(resolveExportedConstExpression(analysis, name) ?? undefined); } /** @@ -107,504 +152,467 @@ export function extractExportConstNumber(code: string, name: string): number | n * 0 — treat as SSR (revalidate every request) * false — fully static (no revalidation) * Infinity — fully static (treated same as false by Next.js) - * null — no `revalidate` key found (fully static) + * null — no local `revalidate` key found, or it could not be inferred */ -export function extractGetStaticPropsRevalidate(code: string): number | false | null { - const returnObjects = extractGetStaticPropsReturnObjects(code); +export function extractGetStaticPropsRevalidate( + code: string, + filePath?: string, +): number | false | null { + return extractGetStaticPropsRevalidateFromAnalysis(getModuleAnalysis(code, filePath)); +} - if (returnObjects) { - for (const searchSpace of returnObjects) { - const revalidate = extractTopLevelRevalidateValue(searchSpace); +function hasNamedExportFromAnalysis(analysis: ModuleAnalysis, name: string): boolean { + return analysis.exports.has(name); +} + +function extractGetStaticPropsRevalidateFromAnalysis( + analysis: ModuleAnalysis, +): number | false | null { + const getStaticProps = resolveGetStaticProps(analysis); + + if (getStaticProps.kind === "absent") { + for (const returnObject of collectTopLevelReturnObjectLiterals(analysis.sourceFile)) { + const revalidate = extractObjectLiteralRevalidate(returnObject); if (revalidate !== null) return revalidate; } return null; } - const m = /\brevalidate\s*:\s*(-?\d+(?:\.\d+)?|Infinity|false)\b/.exec(code); - if (!m) return null; - if (m[1] === "false") return false; - if (m[1] === "Infinity") return Infinity; - return parseFloat(m[1]); -} + if (getStaticProps.kind !== "function") { + return null; + } -function extractTopLevelRevalidateValue(code: string): number | false | null { - let braceDepth = 0; - let parenDepth = 0; - let bracketDepth = 0; - let quote: '"' | "'" | "`" | null = null; - let inLineComment = false; - let inBlockComment = false; + for (const returnObject of collectReturnObjectsFromFunctionLike(getStaticProps.fn)) { + const revalidate = extractObjectLiteralRevalidate(returnObject); + if (revalidate !== null) return revalidate; + } - for (let i = 0; i < code.length; i++) { - const char = code[i]; - const next = code[i + 1]; + return null; +} - if (inLineComment) { - if (char === "\n") inLineComment = false; - continue; - } +function getModuleAnalysis(code: string, filePath?: string): ModuleAnalysis { + const sourceFile = filePath + ? createSourceFileForPath(code, filePath) + : createBestSourceFile(code); - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - i++; - } - continue; - } + const localBindings = new Map(); + const exports = new Map(); - if (quote) { - if (char === "\\") { - i++; - continue; - } - if (char === quote) quote = null; - continue; - } + for (const statement of sourceFile.statements) { + collectLocalBindings(statement, localBindings); + } - if (char === "/" && next === "/") { - inLineComment = true; - i++; - continue; - } + for (const statement of sourceFile.statements) { + collectExports(statement, exports, localBindings); + } - if (char === "/" && next === "*") { - inBlockComment = true; - i++; - continue; - } + return { sourceFile, localBindings, exports }; +} - if (char === '"' || char === "'" || char === "`") { - quote = char; - continue; - } +function createBestSourceFile(code: string): ts.SourceFile { + const preferredKind = ts.ScriptKind.TSX; + const candidates = [ + createSourceFileForKind(code, ts.ScriptKind.TS), + createSourceFileForKind(code, ts.ScriptKind.TSX), + ]; - if (char === "{") { - braceDepth++; - continue; - } + let best = candidates[0]; - if (char === "}") { - braceDepth--; + for (const candidate of candidates.slice(1)) { + if (candidate.diagnosticCount < best.diagnosticCount) { + best = candidate; continue; } - if (char === "(") { - parenDepth++; - continue; + if ( + candidate.diagnosticCount === best.diagnosticCount && + candidate.scriptKind === preferredKind && + best.scriptKind !== preferredKind + ) { + best = candidate; } + } - if (char === ")") { - parenDepth--; - continue; - } + return best.sourceFile; +} - if (char === "[") { - bracketDepth++; - continue; - } +function createSourceFileForPath(code: string, filePath: string): ts.SourceFile { + const scriptKind = getScriptKindFromFilePath(filePath); + if (scriptKind === null) { + return createBestSourceFile(code); + } - if (char === "]") { - bracketDepth--; - continue; - } + return createSourceFileForKind(code, scriptKind, filePath).sourceFile; +} - if ( - braceDepth === 1 && - parenDepth === 0 && - bracketDepth === 0 && - matchesKeywordAt(code, i, "revalidate") - ) { - const colonIndex = findNextNonWhitespaceIndex(code, i + "revalidate".length); - if (colonIndex === -1 || code[colonIndex] !== ":") continue; +function createSourceFileForKind( + code: string, + scriptKind: ts.ScriptKind, + fileName = getSyntheticFileName(scriptKind), +): ParsedSourceCandidate { + const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, scriptKind); + return { + sourceFile, + diagnosticCount: getParseDiagnosticCount(sourceFile), + scriptKind, + }; +} - const valueStart = findNextNonWhitespaceIndex(code, colonIndex + 1); - if (valueStart === -1) return null; +function getSyntheticFileName(scriptKind: ts.ScriptKind): string { + switch (scriptKind) { + case ts.ScriptKind.TS: + return "report-analysis.ts"; + case ts.ScriptKind.TSX: + return "report-analysis.tsx"; + case ts.ScriptKind.JS: + return "report-analysis.js"; + case ts.ScriptKind.JSX: + return "report-analysis.jsx"; + default: + return "report-analysis.tsx"; + } +} - const valueMatch = /^(-?\d+(?:\.\d+)?|Infinity|false)\b/.exec(code.slice(valueStart)); - if (!valueMatch) return null; - if (valueMatch[1] === "false") return false; - if (valueMatch[1] === "Infinity") return Infinity; - return parseFloat(valueMatch[1]); - } +function getScriptKindFromFilePath(filePath: string): ts.ScriptKind | null { + switch (path.extname(filePath).toLowerCase()) { + case ".ts": + case ".mts": + case ".cts": + return ts.ScriptKind.TS; + case ".tsx": + return ts.ScriptKind.TSX; + case ".js": + case ".mjs": + case ".cjs": + return ts.ScriptKind.JS; + case ".jsx": + return ts.ScriptKind.JSX; + default: + return null; } +} - return null; +function getParseDiagnosticCount(sourceFile: ts.SourceFile): number { + return ( + (sourceFile as ts.SourceFile & { parseDiagnostics?: readonly ts.DiagnosticWithLocation[] }) + .parseDiagnostics?.length ?? 0 + ); } -function extractGetStaticPropsReturnObjects(code: string): string[] | null { - const declarationMatch = - /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+getStaticProps\b|(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+getStaticProps\b/.exec( - code, - ); - if (!declarationMatch) { - // A file can re-export getStaticProps from another module without defining - // it locally. In that case we can't safely infer revalidate from this file, - // so skip the whole-file fallback to avoid unrelated false positives. - if (/(?:^|\n)\s*export\s*\{[^}]*\bgetStaticProps\b[^}]*\}\s*from\b/.test(code)) { - return []; +function collectLocalBindings( + statement: ts.Statement, + localBindings: Map, +): void { + if (ts.isFunctionDeclaration(statement) && statement.name) { + localBindings.set(statement.name.text, statement); + return; + } + + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + localBindings.set(declaration.name.text, declaration); + } } - return null; + } +} + +function collectExports( + statement: ts.Statement, + exports: Map, + localBindings: Map, +): void { + if (ts.isFunctionDeclaration(statement) && statement.name && hasNamedExportModifier(statement)) { + exports.set(statement.name.text, { kind: "local", localName: statement.name.text }); + return; } - const declaration = extractGetStaticPropsDeclaration(code, declarationMatch); - if (declaration === null) return []; + if (ts.isVariableStatement(statement) && hasNamedExportModifier(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + exports.set(declaration.name.text, { kind: "local", localName: declaration.name.text }); + } + } + } - const returnObjects = declaration.trimStart().startsWith("{") - ? collectReturnObjectsFromFunctionBody(declaration) - : []; + if (!ts.isExportDeclaration(statement) || !statement.exportClause) { + return; + } - if (returnObjects.length > 0) return returnObjects; + if (statement.isTypeOnly) { + return; + } - const arrowMatch = declaration.search(/=>\s*\(\s*\{/); - // getStaticProps was found but contains no return objects — return empty - // (non-null signals the caller to skip the whole-file fallback). - if (arrowMatch === -1) return []; + if (!ts.isNamedExports(statement.exportClause)) { + return; + } - const braceStart = declaration.indexOf("{", arrowMatch); - if (braceStart === -1) return []; + for (const specifier of statement.exportClause.elements) { + if (specifier.isTypeOnly) { + continue; + } - const braceEnd = findMatchingBrace(declaration, braceStart); - if (braceEnd === -1) return []; + const exportName = specifier.name.text; + if (statement.moduleSpecifier) { + exports.set(exportName, { kind: "reexport" }); + continue; + } - return [declaration.slice(braceStart, braceEnd + 1)]; + const localName = specifier.propertyName?.text ?? exportName; + exports.set( + exportName, + localBindings.has(localName) ? { kind: "local", localName } : { kind: "reexport" }, + ); + } } -function extractGetStaticPropsDeclaration( - code: string, - declarationMatch: RegExpExecArray, -): string | null { - const declarationStart = declarationMatch.index; - const declarationText = declarationMatch[0]; - const declarationTail = code.slice(declarationStart); +function hasNamedExportModifier(node: ts.Node): boolean { + if (!ts.canHaveModifiers(node)) return false; - if (declarationText.includes("function getStaticProps")) { - return extractFunctionBody(code, declarationStart + declarationText.length); - } + const modifiers = ts.getModifiers(node) ?? []; + const hasExport = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + const hasDefault = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword); + const hasDeclare = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword); - const functionExpressionMatch = /(?:async\s+)?function\b/.exec(declarationTail); - if (functionExpressionMatch) { - return extractFunctionBody(declarationTail, functionExpressionMatch.index); - } + return hasExport && !hasDefault && !hasDeclare; +} - const blockBodyMatch = /=>\s*\{/.exec(declarationTail); - if (blockBodyMatch) { - const braceStart = declarationTail.indexOf("{", blockBodyMatch.index); - if (braceStart === -1) return null; +function resolveExportedConstExpression( + analysis: ModuleAnalysis, + exportName: string, +): ts.Expression | null { + const resolved = analysis.exports.get(exportName); + if (!resolved || resolved.kind !== "local") return null; - const braceEnd = findMatchingBrace(declarationTail, braceStart); - if (braceEnd === -1) return null; + return resolveLocalConstExpression(analysis, resolved.localName); +} - return declarationTail.slice(braceStart, braceEnd + 1); +function resolveLocalConstExpression( + analysis: ModuleAnalysis, + localName: string, + seen = new Set(), +): ts.Expression | null { + if (seen.has(localName)) return null; + seen.add(localName); + + const declaration = analysis.localBindings.get(localName); + if (!declaration || !ts.isVariableDeclaration(declaration)) return null; + + const declarationList = declaration.parent; + if (!ts.isVariableDeclarationList(declarationList)) return null; + if ((declarationList.flags & ts.NodeFlags.Const) === 0) return null; + + const initializer = unwrapExpression(declaration.initializer); + if (!initializer) return null; + if (ts.isIdentifier(initializer)) { + const resolvedInitializer = resolveLocalConstExpression(analysis, initializer.text, seen); + return resolvedInitializer ?? initializer; } - const implicitArrowMatch = declarationTail.search(/=>\s*\(\s*\{/); - if (implicitArrowMatch === -1) return null; + return initializer; +} - const implicitBraceStart = declarationTail.indexOf("{", implicitArrowMatch); - if (implicitBraceStart === -1) return null; +function resolveGetStaticProps(analysis: ModuleAnalysis): GetStaticPropsResolution { + const exportedBinding = analysis.exports.get("getStaticProps"); + if (!exportedBinding) return { kind: "absent" }; + if (exportedBinding.kind === "reexport") return { kind: "reexport" }; - const implicitBraceEnd = findMatchingBrace(declarationTail, implicitBraceStart); - if (implicitBraceEnd === -1) return null; + const fn = resolveLocalFunctionLike(analysis, exportedBinding.localName); + if (!fn) return { kind: "unresolved" }; - return declarationTail.slice(0, implicitBraceEnd + 1); + return { kind: "function", fn }; } -function extractFunctionBody(code: string, functionStart: number): string | null { - const bodyEnd = findFunctionBodyEnd(code, functionStart); - if (bodyEnd === -1) return null; +function resolveLocalFunctionLike( + analysis: ModuleAnalysis, + localName: string, + seen = new Set(), +): ResolvedFunctionLike | null { + if (seen.has(localName)) return null; + seen.add(localName); - const paramsStart = code.indexOf("(", functionStart); - if (paramsStart === -1) return null; + const declaration = analysis.localBindings.get(localName); - const paramsEnd = findMatchingParen(code, paramsStart); - if (paramsEnd === -1) return null; + if (declaration && ts.isFunctionDeclaration(declaration)) { + return declaration; + } - const bodyStart = code.indexOf("{", paramsEnd + 1); - if (bodyStart === -1) return null; + if (!declaration || !ts.isVariableDeclaration(declaration)) { + return null; + } - return code.slice(bodyStart, bodyEnd + 1); -} + const initializer = unwrapExpression(declaration.initializer); + if (!initializer) return null; -function collectReturnObjectsFromFunctionBody(code: string): string[] { - const returnObjects: string[] = []; - let quote: '"' | "'" | "`" | null = null; - let inLineComment = false; - let inBlockComment = false; + if (ts.isFunctionExpression(initializer) || ts.isArrowFunction(initializer)) { + return initializer; + } - for (let i = 0; i < code.length; i++) { - const char = code[i]; - const next = code[i + 1]; + if (ts.isIdentifier(initializer)) { + return resolveLocalFunctionLike(analysis, initializer.text, seen); + } - if (inLineComment) { - if (char === "\n") inLineComment = false; - continue; - } + return null; +} - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - i++; - } +function unwrapExpression(expression: ts.Expression | undefined): ts.Expression | undefined { + let current = expression; + + while (current) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; continue; } - if (quote) { - if (char === "\\") { - i++; - continue; - } - if (char === quote) quote = null; + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; continue; } - if (char === "/" && next === "/") { - inLineComment = true; - i++; + if (ts.isSatisfiesExpression(current)) { + current = current.expression; continue; } - if (char === "/" && next === "*") { - inBlockComment = true; - i++; + if (ts.isNonNullExpression(current)) { + current = current.expression; continue; } - if (char === '"' || char === "'" || char === "`") { - quote = char; - continue; + break; + } + + return current; +} + +function collectReturnObjectsFromFunctionLike( + fn: ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction, +): ts.ObjectLiteralExpression[] { + if (!fn.body) return []; + + if (!ts.isBlock(fn.body)) { + const body = unwrapExpression(fn.body); + if (body && ts.isObjectLiteralExpression(body)) { + return [body]; } + return []; + } + + return collectTopLevelReturnObjectLiterals(fn.body); +} + +function collectTopLevelReturnObjectLiterals( + container: ts.Block | ts.SourceFile, +): ts.ObjectLiteralExpression[] { + const returnObjects: ts.ObjectLiteralExpression[] = []; - if (matchesKeywordAt(code, i, "function")) { - const nestedBodyEnd = findFunctionBodyEnd(code, i); - if (nestedBodyEnd !== -1) { - i = nestedBodyEnd; + const visitStatement = (statement: ts.Statement): void => { + if (ts.isReturnStatement(statement)) { + const expression = unwrapExpression(statement.expression); + if (expression && ts.isObjectLiteralExpression(expression)) { + returnObjects.push(expression); } - continue; + return; } - if (matchesKeywordAt(code, i, "class")) { - const classBodyEnd = findClassBodyEnd(code, i); - if (classBodyEnd !== -1) { - i = classBodyEnd; - } - continue; + if (ts.isBlock(statement)) { + for (const child of statement.statements) visitStatement(child); + return; } - if (char === "=" && next === ">") { - const nestedBodyEnd = findArrowFunctionBodyEnd(code, i); - if (nestedBodyEnd !== -1) { - i = nestedBodyEnd; - } - continue; + if (ts.isIfStatement(statement)) { + visitStatement(statement.thenStatement); + if (statement.elseStatement) visitStatement(statement.elseStatement); + return; } if ( - (char >= "A" && char <= "Z") || - (char >= "a" && char <= "z") || - char === "_" || - char === "$" || - char === "*" + ts.isForStatement(statement) || + ts.isForInStatement(statement) || + ts.isForOfStatement(statement) || + ts.isWhileStatement(statement) || + ts.isDoStatement(statement) || + ts.isLabeledStatement(statement) || + ts.isWithStatement(statement) ) { - const methodBodyEnd = findObjectMethodBodyEnd(code, i); - if (methodBodyEnd !== -1) { - i = methodBodyEnd; - continue; - } + visitStatement(statement.statement); + return; } - if (matchesKeywordAt(code, i, "return")) { - const braceStart = findNextNonWhitespaceIndex(code, i + "return".length); - if (braceStart === -1 || code[braceStart] !== "{") continue; - - const braceEnd = findMatchingBrace(code, braceStart); - if (braceEnd === -1) continue; + if (ts.isSwitchStatement(statement)) { + for (const clause of statement.caseBlock.clauses) { + for (const child of clause.statements) visitStatement(child); + } + return; + } - returnObjects.push(code.slice(braceStart, braceEnd + 1)); - i = braceEnd; + if (ts.isTryStatement(statement)) { + visitStatement(statement.tryBlock); + if (statement.catchClause) visitStatement(statement.catchClause.block); + if (statement.finallyBlock) visitStatement(statement.finallyBlock); } + }; + + for (const statement of container.statements) { + visitStatement(statement); } return returnObjects; } -function findFunctionBodyEnd(code: string, functionStart: number): number { - const paramsStart = code.indexOf("(", functionStart); - if (paramsStart === -1) return -1; - - const paramsEnd = findMatchingParen(code, paramsStart); - if (paramsEnd === -1) return -1; - - const bodyStart = code.indexOf("{", paramsEnd + 1); - if (bodyStart === -1) return -1; - - return findMatchingBrace(code, bodyStart); -} +function extractObjectLiteralRevalidate(node: ts.ObjectLiteralExpression): number | false | null { + for (const property of node.properties) { + if (!ts.isPropertyAssignment(property)) continue; + if (getPropertyNameText(property.name) !== "revalidate") continue; -function findClassBodyEnd(code: string, classStart: number): number { - const bodyStart = code.indexOf("{", classStart + "class".length); - if (bodyStart === -1) return -1; + const value = extractLiteralRevalidateValue(property.initializer); + if (value !== null) return value; + return null; + } - return findMatchingBrace(code, bodyStart); + return null; } -function findArrowFunctionBodyEnd(code: string, arrowIndex: number): number { - const bodyStart = findNextNonWhitespaceIndex(code, arrowIndex + 2); - if (bodyStart === -1 || code[bodyStart] !== "{") return -1; +function getPropertyNameText(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } - return findMatchingBrace(code, bodyStart); + return null; } -function findObjectMethodBodyEnd(code: string, start: number): number { - let i = start; +function extractLiteralRevalidateValue(expression: ts.Expression): number | false | null { + const initializer = unwrapExpression(expression); + if (!initializer) return null; - if (matchesKeywordAt(code, i, "async")) { - const afterAsync = findNextNonWhitespaceIndex(code, i + "async".length); - if (afterAsync === -1) return -1; - if (code[afterAsync] !== "(") { - i = afterAsync; - } + if (initializer.kind === ts.SyntaxKind.FalseKeyword) { + return false; } - if (code[i] === "*") { - i = findNextNonWhitespaceIndex(code, i + 1); - if (i === -1) return -1; - } + return extractNumericLiteralValue(initializer); +} - if (!/[A-Za-z_$]/.test(code[i] ?? "")) return -1; +function extractNumericLiteralValue(expression: ts.Expression | undefined): number | null { + const initializer = unwrapExpression(expression); + if (!initializer) return null; - const nameStart = i; - while (/[A-Za-z0-9_$]/.test(code[i] ?? "")) i++; - const name = code.slice(nameStart, i); + if (ts.isNumericLiteral(initializer)) { + return Number(initializer.text); + } if ( - name === "if" || - name === "for" || - name === "while" || - name === "switch" || - name === "catch" || - name === "function" || - name === "return" || - name === "const" || - name === "let" || - name === "var" || - name === "new" + ts.isPrefixUnaryExpression(initializer) && + initializer.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(initializer.operand) ) { - return -1; + return -Number(initializer.operand.text); } - if (name === "get" || name === "set") { - const afterAccessor = findNextNonWhitespaceIndex(code, i); - if (afterAccessor === -1) return -1; - if (code[afterAccessor] !== "(") { - i = afterAccessor; - if (!/[A-Za-z_$]/.test(code[i] ?? "")) return -1; - while (/[A-Za-z0-9_$]/.test(code[i] ?? "")) i++; - } + if (ts.isIdentifier(initializer) && initializer.text === "Infinity") { + return Infinity; } - const paramsStart = findNextNonWhitespaceIndex(code, i); - if (paramsStart === -1 || code[paramsStart] !== "(") return -1; - - const paramsEnd = findMatchingParen(code, paramsStart); - if (paramsEnd === -1) return -1; - - const bodyStart = findNextNonWhitespaceIndex(code, paramsEnd + 1); - if (bodyStart === -1 || code[bodyStart] !== "{") return -1; - - return findMatchingBrace(code, bodyStart); -} - -function findNextNonWhitespaceIndex(code: string, start: number): number { - for (let i = start; i < code.length; i++) { - if (!/\s/.test(code[i])) return i; - } - return -1; -} - -function matchesKeywordAt(code: string, index: number, keyword: string): boolean { - const before = index === 0 ? "" : code[index - 1]; - const after = code[index + keyword.length] ?? ""; - return ( - code.startsWith(keyword, index) && - (before === "" || !/[A-Za-z0-9_$]/.test(before)) && - (after === "" || !/[A-Za-z0-9_$]/.test(after)) - ); -} - -function findMatchingBrace(code: string, start: number): number { - return findMatchingToken(code, start, "{", "}"); -} - -function findMatchingParen(code: string, start: number): number { - return findMatchingToken(code, start, "(", ")"); -} - -function findMatchingToken( - code: string, - start: number, - openToken: string, - closeToken: string, -): number { - let depth = 0; - let quote: '"' | "'" | "`" | null = null; - let inLineComment = false; - let inBlockComment = false; - - for (let i = start; i < code.length; i++) { - const char = code[i]; - const next = code[i + 1]; - - if (inLineComment) { - if (char === "\n") inLineComment = false; - continue; - } - - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - i++; - } - continue; - } - - if (quote) { - if (char === "\\") { - i++; - continue; - } - if (char === quote) quote = null; - continue; - } - - if (char === "/" && next === "/") { - inLineComment = true; - i++; - continue; - } - - if (char === "/" && next === "*") { - inBlockComment = true; - i++; - continue; - } - - if (char === '"' || char === "'" || char === "`") { - quote = char; - continue; - } - - if (char === openToken) { - depth++; - continue; - } - - if (char === closeToken) { - depth--; - if (depth === 0) return i; - } - } - - return -1; + return null; } // ─── Route classification ───────────────────────────────────────────────────── @@ -632,12 +640,19 @@ export function classifyPagesRoute(filePath: string): { return { type: "unknown" }; } - if (hasNamedExport(code, "getServerSideProps")) { + const analysis = getModuleAnalysis(code, filePath); + + if (hasNamedExportFromAnalysis(analysis, "getServerSideProps")) { return { type: "ssr" }; } - if (hasNamedExport(code, "getStaticProps")) { - const revalidate = extractGetStaticPropsRevalidate(code); + if (hasNamedExportFromAnalysis(analysis, "getStaticProps")) { + const getStaticProps = resolveGetStaticProps(analysis); + if (getStaticProps.kind === "reexport" || getStaticProps.kind === "unresolved") { + return { type: "unknown" }; + } + + const revalidate = extractGetStaticPropsRevalidateFromAnalysis(analysis); if (revalidate === null || revalidate === false || revalidate === Infinity) { return { type: "static" }; @@ -679,8 +694,10 @@ export function classifyAppRoute( return { type: "unknown" }; } + const analysis = getModuleAnalysis(code, filePath); + // Check `export const dynamic` - const dynamicValue = extractExportConstString(code, "dynamic"); + const dynamicValue = extractExportConstStringFromAnalysis(analysis, "dynamic"); if (dynamicValue === "force-dynamic") { return { type: "ssr" }; } @@ -691,7 +708,7 @@ export function classifyAppRoute( } // Check `export const revalidate` - const revalidateValue = extractExportConstNumber(code, "revalidate"); + const revalidateValue = extractExportConstNumberFromAnalysis(analysis, "revalidate"); if (revalidateValue !== null) { if (revalidateValue === Infinity) return { type: "static" }; if (revalidateValue === 0) return { type: "ssr" }; @@ -736,7 +753,12 @@ export function buildReportRows(options: { for (const route of options.pageRoutes ?? []) { const { type, revalidate } = classifyPagesRoute(route.filePath); - rows.push({ pattern: route.pattern, type, revalidate }); + if (type === "unknown" && renderedRoutes.has(route.pattern)) { + // Speculative prerender confirmed this route is static. + rows.push({ pattern: route.pattern, type: "static", prerendered: true }); + } else { + rows.push({ pattern: route.pattern, type, revalidate }); + } } for (const route of options.apiRoutes ?? []) { @@ -863,7 +885,7 @@ export function findDir(root: string, ...candidates: string[]): string | null { */ export async function printBuildReport(options: { root: string; - pageExtensions: string[]; + pageExtensions?: string[]; prerenderResult?: PrerenderResult; }): Promise { const { root } = options; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15a69e1d..91e345ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -810,6 +810,9 @@ importers: rsc-html-stream: specifier: 'catalog:' version: 0.0.7 + typescript: + specifier: 'catalog:' + version: 5.9.3 vite-plugin-commonjs: specifier: 'catalog:' version: 0.10.4 diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index d1e63e32..ab8e9ddf 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -1,14 +1,12 @@ /** * Build report tests — verifies route classification, formatting, and sorting. * - * Tests the regex-based export detection helpers and the classification + * Tests the AST-based export detection helpers and the classification * logic for both Pages Router and App Router routes, using real fixture files * where integration testing is needed. */ -import { describe, it, expect, afterEach } from "vite-plus/test"; +import { describe, it, expect } from "vite-plus/test"; import path from "node:path"; -import os from "node:os"; -import fs from "node:fs/promises"; import { hasNamedExport, extractExportConstString, @@ -18,12 +16,10 @@ import { classifyAppRoute, buildReportRows, formatBuildReport, - printBuildReport, } 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"; const FIXTURES_PAGES = path.resolve("tests/fixtures/pages-basic/pages"); +const FIXTURES_BUILD_REPORT = path.resolve("tests/fixtures/build-report/pages"); const FIXTURES_APP = path.resolve("tests/fixtures/app-basic/app"); // ─── hasNamedExport ─────────────────────────────────────────────────────────── @@ -54,13 +50,54 @@ describe("hasNamedExport", () => { }); it("detects re-export with alias", () => { - expect(hasNamedExport("export { getStaticProps as gsp };", "getStaticProps")).toBe(true); + expect(hasNamedExport("export { getStaticProps as gsp };", "gsp")).toBe(true); + }); + + it("detects aliased export name from a local binding", () => { + expect(hasNamedExport("const foo = 1; export { foo as revalidate };", "revalidate")).toBe(true); + }); + + it("does not treat the local side of an aliased export as the exported name", () => { + expect(hasNamedExport("export { getStaticProps as helper };", "getStaticProps")).toBe(false); }); it("returns false when export is absent", () => { expect(hasNamedExport("export default function Page() {}", "getStaticProps")).toBe(false); }); + it("does not treat default-exported getStaticProps as a named export", () => { + expect(hasNamedExport("export default function getStaticProps() {}", "getStaticProps")).toBe( + false, + ); + }); + + it("does not treat default-exported getServerSideProps as a named export", () => { + expect( + hasNamedExport("export default async function getServerSideProps() {}", "getServerSideProps"), + ).toBe(false); + }); + + it("does not treat `export type` re-exports as named runtime exports", () => { + expect( + hasNamedExport('export type { getStaticProps } from "./shared";', "getStaticProps"), + ).toBe(false); + }); + + it("does not treat `export { type ... }` specifiers as named runtime exports", () => { + expect( + hasNamedExport('export { type getServerSideProps } from "./shared";', "getServerSideProps"), + ).toBe(false); + }); + + it("does not treat declared exports as named runtime exports", () => { + expect( + hasNamedExport( + "export declare function getServerSideProps(): Promise;", + "getServerSideProps", + ), + ).toBe(false); + }); + it("does not match partial names (false positive guard)", () => { // 'getStaticPropsExtra' should not match 'getStaticProps' expect(hasNamedExport("export function getStaticPropsExtra() {}", "getStaticProps")).toBe( @@ -99,6 +136,21 @@ describe("extractExportConstString", () => { ); }); + it("extracts value from an `as const` string export", () => { + expect( + extractExportConstString("export const dynamic = 'force-static' as const;", "dynamic"), + ).toBe("force-static"); + }); + + it("extracts value from a `satisfies` string export", () => { + expect( + extractExportConstString( + "export const dynamic = 'force-static' satisfies string;", + "dynamic", + ), + ).toBe("force-static"); + }); + it("returns null when export is absent", () => { expect(extractExportConstString("export const revalidate = 60;", "dynamic")).toBeNull(); }); @@ -106,6 +158,18 @@ describe("extractExportConstString", () => { it("returns null for non-string value", () => { expect(extractExportConstString("export const revalidate = 60;", "revalidate")).toBeNull(); }); + + it("extracts string from a local const re-exported by specifier", () => { + expect( + extractExportConstString("const mode = 'error'; export { mode as dynamic };", "dynamic"), + ).toBe("error"); + }); + + it("extracts string from a local const identifier alias", () => { + expect( + extractExportConstString("const mode = 'error'; export const dynamic = mode;", "dynamic"), + ).toBe("error"); + }); }); // ─── extractExportConstNumber ───────────────────────────────────────────────── @@ -135,9 +199,39 @@ describe("extractExportConstNumber", () => { ); }); + it("extracts from an `as const` numeric export", () => { + expect(extractExportConstNumber("export const revalidate = 60 as const;", "revalidate")).toBe( + 60, + ); + }); + + it("extracts from a `satisfies` numeric export", () => { + expect( + extractExportConstNumber("export const revalidate = 60 satisfies number;", "revalidate"), + ).toBe(60); + }); + it("returns null when export is absent", () => { expect(extractExportConstNumber("export const dynamic = 'auto';", "revalidate")).toBeNull(); }); + + it("extracts number from a local const re-exported by specifier", () => { + expect( + extractExportConstNumber( + "const interval = 120; export { interval as revalidate };", + "revalidate", + ), + ).toBe(120); + }); + + it("extracts number from a local const identifier alias", () => { + expect( + extractExportConstNumber( + "const interval = 120; export const revalidate = interval;", + "revalidate", + ), + ).toBe(120); + }); }); // ─── extractGetStaticPropsRevalidate ────────────────────────────────────────── @@ -150,6 +244,20 @@ describe("extractGetStaticPropsRevalidate", () => { expect(extractGetStaticPropsRevalidate(code)).toBe(60); }); + it("extracts revalidate from an `as const` value inside getStaticProps", () => { + const code = `export async function getStaticProps() { + return { props: {}, revalidate: 60 as const }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("extracts revalidate from a `satisfies` value inside getStaticProps", () => { + const code = `export async function getStaticProps() { + return { props: {}, revalidate: 60 satisfies number }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + // These bare return-object cases intentionally exercise the whole-file // fallback path used when no local getStaticProps declaration is present. it("extracts revalidate: 0 (treat as SSR)", () => { @@ -227,6 +335,17 @@ export function unrelated() { expect(extractGetStaticPropsRevalidate(code)).toBe(60); }); + it("extracts revalidate from a generic arrow-function getStaticProps", () => { + const code = `export const getStaticProps = () => ({ props: {}, revalidate: 60 });`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("respects the provided file extension when parsing generic arrow getStaticProps", () => { + const code = `export const getStaticProps = () => ({ props: {}, revalidate: 60 });`; + expect(extractGetStaticPropsRevalidate(code, "page.ts")).toBe(60); + expect(extractGetStaticPropsRevalidate(code, "page.tsx")).toBeNull(); + }); + it("ignores revalidate in a nested helper function inside getStaticProps", () => { const code = `export function getStaticProps() { const helper = () => { @@ -317,12 +436,57 @@ export { getStaticProps } from "./shared"; expect(extractGetStaticPropsRevalidate(code)).toBeNull(); }); + it("ignores fallback-path returns when an imported getStaticProps is re-exported locally", () => { + const code = `import { getStaticProps } from "./shared"; +export { getStaticProps }; + +return { props: {}, revalidate: 1 };`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); + it("handles inline comment after value (fixture file style)", () => { // From tests/fixtures/pages-basic/pages/isr-test.tsx: // revalidate: 1, // Revalidate every 1 second const code = `return { props: {}, revalidate: 1, // comment\n};`; expect(extractGetStaticPropsRevalidate(code)).toBe(1); }); + + it("extracts revalidate when getStaticProps is exported under an alias", () => { + const code = `const loadStaticProps = async () => { + return { props: {}, revalidate: 60 }; +}; + +export { loadStaticProps as getStaticProps };`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("extracts revalidate when getStaticProps is exported before its local declaration", () => { + const code = `export { getStaticProps }; + +const getStaticProps = async () => { + return { props: {}, revalidate: 60 }; +};`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("extracts revalidate when getStaticProps is exported via a local identifier alias", () => { + const code = `const loadStaticProps = async () => ({ props: {}, revalidate: 60 }); +export const getStaticProps = loadStaticProps;`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60); + }); + + it("does not fall back to unrelated top-level returns for non-analyzable local getStaticProps", () => { + const code = `function createGSP() { + return async function generatedGetStaticProps() { + return { props: {}, revalidate: 60 }; + }; +} + +export const getStaticProps = createGSP(); + +return { props: {}, revalidate: 1 };`; + expect(extractGetStaticPropsRevalidate(code)).toBeNull(); + }); }); // ─── classifyPagesRoute (integration — real fixture files) ──────────────────── @@ -352,6 +516,68 @@ describe("classifyPagesRoute", () => { it("returns unknown on file read failure (consistent with classifyAppRoute)", () => { expect(classifyPagesRoute("/nonexistent/pages/page.tsx")).toEqual({ type: "unknown" }); }); + + it("does not classify an aliased local export as getStaticProps", () => { + const filePath = path.join(FIXTURES_BUILD_REPORT, "build-report-alias-export.tsx"); + expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); + }); + + it("classifies a generic-arrow getStaticProps in a .ts file as isr", () => { + const filePath = path.join(FIXTURES_BUILD_REPORT, "build-report-generic-gsp.ts"); + expect(classifyPagesRoute(filePath)).toEqual({ type: "isr", revalidate: 60 }); + }); + + it("does not classify a default-exported getStaticProps as data fetching", () => { + const filePath = path.resolve( + path.join(FIXTURES_BUILD_REPORT, "build-report-default-export-gsp.tsx"), + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); + }); + + it("does not classify a default-exported getServerSideProps as data fetching", () => { + const filePath = path.resolve( + path.join(FIXTURES_BUILD_REPORT, "build-report-default-export-gssp.tsx"), + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); + }); + + it("does not classify type-only getStaticProps exports as data fetching", () => { + const filePath = path.resolve( + path.join(FIXTURES_BUILD_REPORT, "build-report-type-only-gsp.tsx"), + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); + }); + + it("does not classify type-only getServerSideProps exports as data fetching", () => { + const filePath = path.resolve( + path.join(FIXTURES_BUILD_REPORT, "build-report-type-only-gssp.tsx"), + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "static" }); + }); + + it("classifies direct getStaticProps re-exports as unknown", () => { + const filePath = path.join(FIXTURES_BUILD_REPORT, "build-report-reexport-gsp.tsx"); + expect(classifyPagesRoute(filePath)).toEqual({ type: "unknown" }); + }); + + it("classifies imported getStaticProps re-exports as unknown", () => { + const filePath = path.resolve( + path.join(FIXTURES_BUILD_REPORT, "build-report-import-reexport-gsp.tsx"), + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "unknown" }); + }); + + it("classifies local identifier-aliased getStaticProps as isr", () => { + const filePath = path.resolve( + path.join(FIXTURES_BUILD_REPORT, "build-report-local-identifier-gsp.tsx"), + ); + expect(classifyPagesRoute(filePath)).toEqual({ type: "isr", revalidate: 60 }); + }); + + it("classifies non-analyzable local getStaticProps factories as unknown", () => { + const filePath = path.join(FIXTURES_BUILD_REPORT, "build-report-factory-gsp.tsx"); + expect(classifyPagesRoute(filePath)).toEqual({ type: "unknown" }); + }); }); // ─── classifyAppRoute ───────────────────────────────────────────────────────── @@ -480,6 +706,34 @@ describe("buildReportRows", () => { expect(rows[0].pattern).toBe("/aaa"); expect(rows[1].pattern).toBe("/zzz"); }); + + it("upgrades unknown Pages routes to static when speculative prerender rendered them", () => { + const pageRoutes = [ + { + pattern: "/reexported-gsp", + patternParts: ["/reexported-gsp"], + filePath: path.join(FIXTURES_BUILD_REPORT, "build-report-reexport-gsp.tsx"), + isDynamic: false, + params: [], + }, + ]; + + const rows = buildReportRows({ + pageRoutes, + prerenderResult: { + routes: [ + { + route: "/reexported-gsp", + status: "rendered", + outputFiles: ["index.html"], + revalidate: false, + }, + ], + }, + }); + + expect(rows).toEqual([{ pattern: "/reexported-gsp", type: "static", prerendered: true }]); + }); }); // ─── formatBuildReport ──────────────────────────────────────────────────────── @@ -603,139 +857,3 @@ describe("formatBuildReport", () => { expect(out).toContain("λ API ƒ Dynamic ◐ ISR ○ Static"); }); }); - -// ─── printBuildReport with pageExtensions ───────────────────────────────────── - -describe("printBuildReport respects pageExtensions", () => { - let tmpRoot: string; - - afterEach(async () => { - if (tmpRoot) { - // Invalidate both routers' caches — pages router tests set pagesDir at - // tmpRoot/pages, so we invalidate that path too. This ensures a failing - // test that skips its own finally-block cleanup doesn't pollute later tests. - invalidateAppRouteCache(); - invalidateRouteCache(path.join(tmpRoot, "pages")); - await fs.rm(tmpRoot, { recursive: true, force: true }); - } - }); - - it("app router: only reports routes matching configured pageExtensions", async () => { - // Ported from Next.js MDX e2e pageExtensions behaviour: - // test/e2e/app-dir/mdx/next.config.ts - // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/mdx/next.config.ts - tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-report-app-")); - const appDir = path.join(tmpRoot, "app"); - await fs.mkdir(path.join(appDir, "about"), { recursive: true }); - await fs.writeFile( - path.join(appDir, "layout.tsx"), - "export default function Layout({ children }: { children: React.ReactNode }) { return {children}; }", - ); - await fs.writeFile( - path.join(appDir, "page.tsx"), - "export default function Page() { return
home
; }", - ); - // This .mdx page should be excluded when mdx is not in pageExtensions - await fs.writeFile(path.join(appDir, "about", "page.mdx"), "# About"); - - // Capture stdout output from printBuildReport - const lines: string[] = []; - const origLog = console.log; - console.log = (msg: string) => lines.push(msg); - try { - invalidateAppRouteCache(); - await printBuildReport({ root: tmpRoot, pageExtensions: ["tsx", "ts", "jsx", "js"] }); - } finally { - console.log = origLog; - } - - const output = lines.join("\n"); - // / should appear (page.tsx matches) - expect(output).toContain("/"); - // /about should NOT appear (page.mdx excluded — mdx not in pageExtensions) - expect(output).not.toContain("/about"); - }); - - it("app router: reports mdx routes when pageExtensions includes mdx", async () => { - tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-report-app-mdx-")); - const appDir = path.join(tmpRoot, "app"); - await fs.mkdir(path.join(appDir, "about"), { recursive: true }); - await fs.writeFile( - path.join(appDir, "layout.tsx"), - "export default function Layout({ children }: { children: React.ReactNode }) { return {children}; }", - ); - await fs.writeFile( - path.join(appDir, "page.tsx"), - "export default function Page() { return
home
; }", - ); - await fs.writeFile(path.join(appDir, "about", "page.mdx"), "# About"); - - const lines: string[] = []; - const origLog = console.log; - console.log = (msg: string) => lines.push(msg); - try { - invalidateAppRouteCache(); - await printBuildReport({ root: tmpRoot, pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }); - } finally { - console.log = origLog; - } - - const output = lines.join("\n"); - expect(output).toContain("/about"); - }); - - it("pages router: only reports routes matching configured pageExtensions", async () => { - tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-report-pages-")); - const pagesDir = path.join(tmpRoot, "pages"); - await fs.mkdir(pagesDir, { recursive: true }); - await fs.writeFile( - path.join(pagesDir, "index.tsx"), - "export default function Page() { return
home
; }", - ); - // This .mdx page should be excluded when mdx is not in pageExtensions - await fs.writeFile(path.join(pagesDir, "about.mdx"), "# About"); - - const lines: string[] = []; - const origLog = console.log; - console.log = (msg: string) => lines.push(msg); - try { - invalidateRouteCache(pagesDir); - await printBuildReport({ root: tmpRoot, pageExtensions: ["tsx", "ts", "jsx", "js"] }); - } finally { - console.log = origLog; - invalidateRouteCache(pagesDir); - } - - const output = lines.join("\n"); - expect(output).toContain("/"); - expect(output).not.toContain("/about"); - }); - - it("pages router: reports mdx routes when pageExtensions includes mdx", async () => { - tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-report-pages-mdx-")); - const pagesDir = path.join(tmpRoot, "pages"); - await fs.mkdir(pagesDir, { recursive: true }); - await fs.writeFile( - path.join(pagesDir, "index.tsx"), - "export default function Page() { return
home
; }", - ); - await fs.writeFile(path.join(pagesDir, "about.mdx"), "# About"); - - const lines: string[] = []; - const origLog = console.log; - console.log = (msg: string) => lines.push(msg); - try { - invalidateRouteCache(pagesDir); - await printBuildReport({ - root: tmpRoot, - pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"], - }); - } finally { - console.log = origLog; - invalidateRouteCache(pagesDir); - } - - const output = lines.join("\n"); - expect(output).toContain("/about"); - }); -}); diff --git a/tests/fixtures/build-report/lib/build-report-shared-gsp.ts b/tests/fixtures/build-report/lib/build-report-shared-gsp.ts new file mode 100644 index 00000000..667eea6c --- /dev/null +++ b/tests/fixtures/build-report/lib/build-report-shared-gsp.ts @@ -0,0 +1,3 @@ +export const getStaticProps = async () => { + return { props: {}, revalidate: 60 }; +}; diff --git a/tests/fixtures/build-report/lib/build-report-types.ts b/tests/fixtures/build-report/lib/build-report-types.ts new file mode 100644 index 00000000..6149106c --- /dev/null +++ b/tests/fixtures/build-report/lib/build-report-types.ts @@ -0,0 +1,7 @@ +export type getStaticProps = { + kind: "getStaticProps"; +}; + +export type getServerSideProps = { + kind: "getServerSideProps"; +}; diff --git a/tests/fixtures/build-report/pages/build-report-alias-export.tsx b/tests/fixtures/build-report/pages/build-report-alias-export.tsx new file mode 100644 index 00000000..89b472ee --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-alias-export.tsx @@ -0,0 +1,7 @@ +const getStaticProps = async () => ({ props: {}, revalidate: 60 }); + +export { getStaticProps as helper }; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/build-report/pages/build-report-default-export-gsp.tsx b/tests/fixtures/build-report/pages/build-report-default-export-gsp.tsx new file mode 100644 index 00000000..2e47d1ba --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-default-export-gsp.tsx @@ -0,0 +1,3 @@ +export default function getStaticProps() { + return null; +} diff --git a/tests/fixtures/build-report/pages/build-report-default-export-gssp.tsx b/tests/fixtures/build-report/pages/build-report-default-export-gssp.tsx new file mode 100644 index 00000000..05bb0d38 --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-default-export-gssp.tsx @@ -0,0 +1,3 @@ +export default async function getServerSideProps() { + return null; +} diff --git a/tests/fixtures/build-report/pages/build-report-factory-gsp.tsx b/tests/fixtures/build-report/pages/build-report-factory-gsp.tsx new file mode 100644 index 00000000..13b049d8 --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-factory-gsp.tsx @@ -0,0 +1,11 @@ +function createGSP() { + return async function generatedGetStaticProps() { + return { props: {}, revalidate: 60 }; + }; +} + +export const getStaticProps = createGSP(); + +export default function Page() { + return null; +} diff --git a/tests/fixtures/build-report/pages/build-report-generic-gsp.ts b/tests/fixtures/build-report/pages/build-report-generic-gsp.ts new file mode 100644 index 00000000..7bacbcb3 --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-generic-gsp.ts @@ -0,0 +1,5 @@ +export const getStaticProps = () => ({ props: {}, revalidate: 60 }); + +export default function Page() { + return null; +} diff --git a/tests/fixtures/build-report/pages/build-report-import-reexport-gsp.tsx b/tests/fixtures/build-report/pages/build-report-import-reexport-gsp.tsx new file mode 100644 index 00000000..f44e40cc --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-import-reexport-gsp.tsx @@ -0,0 +1,7 @@ +import { getStaticProps as sharedGetStaticProps } from "../lib/build-report-shared-gsp"; + +export { sharedGetStaticProps as getStaticProps }; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/build-report/pages/build-report-local-identifier-gsp.tsx b/tests/fixtures/build-report/pages/build-report-local-identifier-gsp.tsx new file mode 100644 index 00000000..90e16164 --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-local-identifier-gsp.tsx @@ -0,0 +1,7 @@ +const loadStaticProps = async () => ({ props: {}, revalidate: 60 }); + +export const getStaticProps = loadStaticProps; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/build-report/pages/build-report-reexport-gsp.tsx b/tests/fixtures/build-report/pages/build-report-reexport-gsp.tsx new file mode 100644 index 00000000..ed29cbef --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-reexport-gsp.tsx @@ -0,0 +1,5 @@ +export { getStaticProps } from "../lib/build-report-shared-gsp"; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/build-report/pages/build-report-type-only-gsp.tsx b/tests/fixtures/build-report/pages/build-report-type-only-gsp.tsx new file mode 100644 index 00000000..3327f07f --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-type-only-gsp.tsx @@ -0,0 +1,5 @@ +export type { getStaticProps } from "../lib/build-report-types"; + +export default function Page() { + return null; +} diff --git a/tests/fixtures/build-report/pages/build-report-type-only-gssp.tsx b/tests/fixtures/build-report/pages/build-report-type-only-gssp.tsx new file mode 100644 index 00000000..8c0cede6 --- /dev/null +++ b/tests/fixtures/build-report/pages/build-report-type-only-gssp.tsx @@ -0,0 +1,5 @@ +export { type getServerSideProps } from "../lib/build-report-types"; + +export default function Page() { + return null; +}