diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index e26ef3a48..418f0a910 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). + * Runtime/prerender results are still treated as stronger evidence where + * available; AST analysis only reads top-level static exports. * * Limitation: without running the build, we cannot detect dynamic API usage * (headers(), cookies(), connection(), etc.) that implicitly forces a route @@ -21,6 +21,8 @@ import fs from "node:fs"; import path from "node:path"; +import { parseSync } from "vite"; +import type { ESTree } from "vite"; import type { Route } from "../routing/pages-router.js"; import type { AppRoute } from "../routing/app-router.js"; import type { LayoutBuildClassification } from "./layout-classification-types.js"; @@ -44,6 +46,18 @@ export type RouteRow = { }; type AppRouteRenderEntry = Pick; +type ArrowFunctionExpression = ESTree.ArrowFunctionExpression; +type BindingPattern = ESTree.BindingPattern; +type BlockStatement = ESTree.BlockStatement; +type Expression = ESTree.Expression; +type FunctionBody = ESTree.FunctionBody; +type FunctionLike = ESTree.Function | ArrowFunctionExpression; +type ModuleExportName = ESTree.ModuleExportName; +type ObjectExpression = ESTree.ObjectExpression; +type Program = ESTree.Program; +type PropertyKey = ESTree.PropertyKey; +type Statement = ESTree.Statement; +type VariableDeclarator = ESTree.VariableDeclarator; export function getAppRouteRenderEntryPath(route: AppRouteRenderEntry): string | null { if (route.pagePath) return route.pagePath; @@ -60,44 +74,143 @@ export function getAppRouteRenderEntryPath(route: AppRouteRenderEntry): string | return null; } -// ─── Regex-based export detection ──────────────────────────────────────────── +// ─── Static export analysis ────────────────────────────────────────────────── + +type StaticNumberValue = number | false; + +function parseRouteModuleWithLang(code: string, lang: "ts" | "tsx"): Program | null { + try { + const result = parseSync(`vinext-route.${lang}`, code, { + astType: "ts", + lang, + sourceType: "module", + }); + + return result.errors.some((error) => error.severity === "Error") ? null : result.program; + } catch { + return null; + } +} + +function parseRouteModule(code: string): Program | null { + return parseRouteModuleWithLang(code, "tsx") ?? parseRouteModuleWithLang(code, "ts"); +} + +function moduleExportNameValue(name: ModuleExportName): string | null { + if (name.type === "Identifier") return name.name; + if (name.type === "Literal" && typeof name.value === "string") return name.value; + return null; +} + +function bindingName(pattern: BindingPattern): string | null { + return pattern.type === "Identifier" ? pattern.name : null; +} + +function declarationHasBindingName(declaration: Statement | null, name: string): boolean { + if (declaration === null) return false; + + if (declaration.type === "FunctionDeclaration") { + return declaration.id?.name === name; + } + + if (declaration.type !== "VariableDeclaration") return false; + + return declaration.declarations.some((declaration) => bindingName(declaration.id) === name); +} /** - * Returns true if the source code contains a named export with the given name. + * Returns true if the source code contains an export declaration with the given name. + * For re-export specifiers, this intentionally follows Next.js' static analyzer + * and checks the local/original binding name. * Handles all three common export forms: * export function foo() {} * 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; + const program = parseRouteModule(code); + if (!program) return false; + return hasNamedExportInProgram(program, name); +} - // 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; +function hasNamedExportInProgram(program: Program, name: string): boolean { + for (const node of program.body) { + if (node.type !== "ExportNamedDeclaration") continue; - // 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; + if (declarationHasBindingName(node.declaration, name)) return true; + for (const specifier of node.specifiers) { + if (moduleExportNameValue(specifier.local) === name) { + return true; + } + } + } return false; } +function unwrapStaticExpression(expression: Expression): Expression { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; +} + +function findExportedConstInitializer(code: string, name: string): Expression | null { + const program = parseRouteModule(code); + if (!program) return null; + return findExportedConstInitializerInProgram(program, name); +} + +function findExportedConstInitializerInProgram(program: Program, name: string): Expression | null { + for (const node of program.body) { + if (node.type !== "ExportNamedDeclaration") continue; + const declaration = node.declaration; + if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") continue; + + for (const declarator of declaration.declarations) { + if (bindingName(declarator.id) === name) { + return declarator.init; + } + } + } + + return null; +} + /** * Extracts the string value of `export const = "value"`. - * Handles optional TypeScript type annotations: - * export const dynamic: string = "force-dynamic" + * Handles TypeScript annotations/assertions and no-substitution template literals. * 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; + const initializer = findExportedConstInitializer(code, name); + return extractStringFromConstInitializer(initializer); +} + +function extractExportConstStringFromProgram(program: Program, name: string): string | null { + return extractStringFromConstInitializer(findExportedConstInitializerInProgram(program, name)); +} + +function extractStringFromConstInitializer(initializer: Expression | null): string | null { + if (initializer === null) return null; + + const expression = unwrapStaticExpression(initializer); + if (expression.type === "Literal" && typeof expression.value === "string") { + return expression.value; + } + + if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) { + return expression.quasis[0]?.value.cooked ?? expression.quasis[0]?.value.raw ?? null; + } + + return null; } /** @@ -105,18 +218,24 @@ export function extractExportConstString(code: string, name: string): string | n * Supports integers, decimals, negative values, `Infinity`, and `false`. * `false` is returned as `Infinity` because `export const revalidate = false` * means "cache indefinitely" in Next.js segment config. - * Handles optional TypeScript type annotations. + * Handles TypeScript annotations/assertions and JavaScript numeric separators. * Returns null if the export is absent or not a number/`false`. */ 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|false)(?![\\w$])`, - "m", - ); - const m = re.exec(code); - if (!m) return null; - if (m[1] === "Infinity" || m[1] === "false") return Infinity; - return parseFloat(m[1]); + const initializer = findExportedConstInitializer(code, name); + return extractNumberFromConstInitializer(initializer); +} + +function extractExportConstNumberFromProgram(program: Program, name: string): number | null { + return extractNumberFromConstInitializer(findExportedConstInitializerInProgram(program, name)); +} + +function extractNumberFromConstInitializer(initializer: Expression | null): number | null { + if (initializer === null) return null; + + const value = extractStaticNumberValue(initializer); + if (value === null) return null; + return value === false ? Infinity : value; } /** @@ -131,501 +250,209 @@ export function extractExportConstNumber(code: string, name: string): number | n * null — no `revalidate` key found (fully static) */ export function extractGetStaticPropsRevalidate(code: string): number | false | null { - const returnObjects = extractGetStaticPropsReturnObjects(code); - - if (returnObjects) { - for (const searchSpace of returnObjects) { - const revalidate = extractTopLevelRevalidateValue(searchSpace); - 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]); -} - -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 (let i = 0; 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 === "{") { - braceDepth++; - continue; - } - - if (char === "}") { - braceDepth--; - continue; - } - - if (char === "(") { - parenDepth++; - continue; - } - - if (char === ")") { - parenDepth--; - continue; - } - - if (char === "[") { - bracketDepth++; - continue; - } - - if (char === "]") { - bracketDepth--; - continue; - } - - if ( - braceDepth === 1 && - parenDepth === 0 && - bracketDepth === 0 && - matchesKeywordAt(code, i, "revalidate") - ) { - const colonIndex = findNextNonWhitespaceIndex(code, i + "revalidate".length); - if (colonIndex === -1 || code[colonIndex] !== ":") continue; - - const valueStart = findNextNonWhitespaceIndex(code, colonIndex + 1); - if (valueStart === -1) return null; - - 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]); - } - } - - return null; + const program = parseRouteModule(code); + if (!program) return extractWrappedGetStaticPropsRevalidate(code); + return extractGetStaticPropsRevalidateFromProgram(program, code); } -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 []; - } - return null; - } - - const declaration = extractGetStaticPropsDeclaration(code, declarationMatch); - if (declaration === null) return []; - - const returnObjects = declaration.trimStart().startsWith("{") - ? collectReturnObjectsFromFunctionBody(declaration) - : []; - - if (returnObjects.length > 0) return returnObjects; +function extractGetStaticPropsRevalidateFromProgram( + program: Program, + fallbackCode: string, +): number | false | null { + const getStaticProps = findExportedGetStaticProps(program); + if (getStaticProps === "external") return null; + if (getStaticProps === null) return extractWrappedGetStaticPropsRevalidate(fallbackCode); - 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 []; - - const braceStart = declaration.indexOf("{", arrowMatch); - if (braceStart === -1) return []; - - const braceEnd = findMatchingBrace(declaration, braceStart); - if (braceEnd === -1) return []; - - return [declaration.slice(braceStart, braceEnd + 1)]; + return extractFunctionRevalidate(getStaticProps); } -function extractGetStaticPropsDeclaration( - code: string, - declarationMatch: RegExpExecArray, -): string | null { - const declarationStart = declarationMatch.index; - const declarationText = declarationMatch[0]; - const declarationTail = code.slice(declarationStart); +function extractStaticNumberValue(expression: Expression): StaticNumberValue | null { + const unwrapped = unwrapStaticExpression(expression); - if (declarationText.includes("function getStaticProps")) { - return extractFunctionBody(code, declarationStart + declarationText.length); + if (unwrapped.type === "Literal") { + if (typeof unwrapped.value === "number") return unwrapped.value; + if (unwrapped.value === false) return false; + return null; } - const functionExpressionMatch = /(?:async\s+)?function\b/.exec(declarationTail); - if (functionExpressionMatch) { - return extractFunctionBody(declarationTail, functionExpressionMatch.index); + if (unwrapped.type === "Identifier" && unwrapped.name === "Infinity") { + return Infinity; } - const blockBodyMatch = /=>\s*\{/.exec(declarationTail); - if (blockBodyMatch) { - const braceStart = declarationTail.indexOf("{", blockBodyMatch.index); - if (braceStart === -1) return null; - - const braceEnd = findMatchingBrace(declarationTail, braceStart); - if (braceEnd === -1) return null; - - return declarationTail.slice(braceStart, braceEnd + 1); + if (unwrapped.type === "UnaryExpression") { + const argument = extractStaticNumberValue(unwrapped.argument); + if (typeof argument !== "number") return null; + if (unwrapped.operator === "-") return -argument; + if (unwrapped.operator === "+") return argument; + return null; } - const implicitArrowMatch = declarationTail.search(/=>\s*\(\s*\{/); - if (implicitArrowMatch === -1) return null; - - const implicitBraceStart = declarationTail.indexOf("{", implicitArrowMatch); - if (implicitBraceStart === -1) return null; - - const implicitBraceEnd = findMatchingBrace(declarationTail, implicitBraceStart); - if (implicitBraceEnd === -1) return null; - - return declarationTail.slice(0, implicitBraceEnd + 1); -} - -function extractFunctionBody(code: string, functionStart: number): string | null { - const bodyEnd = findFunctionBodyEnd(code, functionStart); - if (bodyEnd === -1) return null; - - const paramsStart = code.indexOf("(", functionStart); - if (paramsStart === -1) return null; - - const paramsEnd = findMatchingParen(code, paramsStart); - if (paramsEnd === -1) return null; - - const bodyStart = code.indexOf("{", paramsEnd + 1); - if (bodyStart === -1) return null; - - return code.slice(bodyStart, bodyEnd + 1); + return null; } -function collectReturnObjectsFromFunctionBody(code: string): string[] { - const returnObjects: string[] = []; - let quote: '"' | "'" | "`" | null = null; - let inLineComment = false; - let inBlockComment = false; - - for (let i = 0; i < code.length; i++) { - const char = code[i]; - const next = code[i + 1]; - - if (inLineComment) { - if (char === "\n") inLineComment = false; - continue; - } +function findExportedGetStaticProps(program: Program): FunctionLike | "external" | null { + let hasLocalGetStaticPropsExport = false; - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - i++; - } - continue; - } + for (const node of program.body) { + if (node.type !== "ExportNamedDeclaration") continue; - if (quote) { - if (char === "\\") { - i++; - continue; - } - if (char === quote) quote = null; - continue; + const declaration = node.declaration; + if (declaration?.type === "FunctionDeclaration" && declaration.id?.name === "getStaticProps") { + return declaration; } - if (char === "/" && next === "/") { - inLineComment = true; - i++; - continue; + if (declaration?.type === "VariableDeclaration") { + const direct = findFunctionLikeVariable(declaration.declarations, "getStaticProps"); + if (direct) return direct; } - if (char === "/" && next === "*") { - inBlockComment = true; - i++; - continue; + for (const specifier of node.specifiers) { + const localName = moduleExportNameValue(specifier.local); + if (localName !== "getStaticProps") continue; + if (node.source !== null) return "external"; + hasLocalGetStaticPropsExport = true; } + } - if (char === '"' || char === "'" || char === "`") { - quote = char; - continue; - } + if (!hasLocalGetStaticPropsExport) return null; - if (matchesKeywordAt(code, i, "function")) { - const nestedBodyEnd = findFunctionBodyEnd(code, i); - if (nestedBodyEnd !== -1) { - i = nestedBodyEnd; - } - continue; + for (const node of program.body) { + if (node.type === "FunctionDeclaration" && node.id?.name === "getStaticProps") { + return node; } - if (matchesKeywordAt(code, i, "class")) { - const classBodyEnd = findClassBodyEnd(code, i); - if (classBodyEnd !== -1) { - i = classBodyEnd; - } - continue; + if (node.type === "VariableDeclaration") { + const local = findFunctionLikeVariable(node.declarations, "getStaticProps"); + if (local) return local; } + } - if (char === "=" && next === ">") { - const nestedBodyEnd = findArrowFunctionBodyEnd(code, i); - if (nestedBodyEnd !== -1) { - i = nestedBodyEnd; - } - continue; - } + return null; +} +function findFunctionLikeVariable( + declarations: readonly VariableDeclarator[], + name: string, +): FunctionLike | null { + for (const declaration of declarations) { + if (bindingName(declaration.id) !== name || declaration.init === null) continue; + const initializer = unwrapStaticExpression(declaration.init); if ( - (char >= "A" && char <= "Z") || - (char >= "a" && char <= "z") || - char === "_" || - char === "$" || - char === "*" + initializer.type === "FunctionExpression" || + initializer.type === "ArrowFunctionExpression" ) { - const methodBodyEnd = findObjectMethodBodyEnd(code, i); - if (methodBodyEnd !== -1) { - i = methodBodyEnd; - continue; - } - } - - 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; - - returnObjects.push(code.slice(braceStart, braceEnd + 1)); - i = braceEnd; + return initializer; } } - return returnObjects; + return null; } -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; +function extractWrappedGetStaticPropsRevalidate(code: string): number | false | null { + // Exported helpers are also used by tests with bare `return { ... }` fragments, + // which are not valid module source until wrapped in a synthetic function. + const program = parseRouteModule(`function __vinextGetStaticProps() {\n${code}\n}`); + if (!program) return null; - const bodyStart = code.indexOf("{", paramsEnd + 1); - if (bodyStart === -1) return -1; + for (const node of program.body) { + if (node.type === "FunctionDeclaration" && node.id?.name === "__vinextGetStaticProps") { + return extractFunctionRevalidate(node); + } + } - return findMatchingBrace(code, bodyStart); + return null; } -function findClassBodyEnd(code: string, classStart: number): number { - const bodyStart = code.indexOf("{", classStart + "class".length); - if (bodyStart === -1) return -1; +function extractFunctionRevalidate(fn: FunctionLike): number | false | null { + if (fn.type === "ArrowFunctionExpression" && fn.body.type !== "BlockStatement") { + const expression = unwrapStaticExpression(fn.body); + return expression.type === "ObjectExpression" ? extractObjectRevalidate(expression) : null; + } - return findMatchingBrace(code, bodyStart); + if (!fn.body || fn.body.type !== "BlockStatement") return null; + return extractBlockRevalidate(fn.body); } -function findArrowFunctionBodyEnd(code: string, arrowIndex: number): number { - const bodyStart = findNextNonWhitespaceIndex(code, arrowIndex + 2); - if (bodyStart === -1 || code[bodyStart] !== "{") return -1; +function extractBlockRevalidate(block: BlockStatement | FunctionBody): number | false | null { + for (const statement of block.body) { + const result = extractStatementRevalidate(statement); + if (result !== null) return result; + } - return findMatchingBrace(code, bodyStart); + return null; } -function findObjectMethodBodyEnd(code: string, start: number): number { - let i = start; - - if (matchesKeywordAt(code, i, "async")) { - const afterAsync = findNextNonWhitespaceIndex(code, i + "async".length); - if (afterAsync === -1) return -1; - if (code[afterAsync] !== "(") { - i = afterAsync; - } +function extractStatementRevalidate(statement: Statement): number | false | null { + if (statement.type === "ReturnStatement") { + if (!statement.argument) return null; + const argument = unwrapStaticExpression(statement.argument); + return argument.type === "ObjectExpression" ? extractObjectRevalidate(argument) : null; } - if (code[i] === "*") { - i = findNextNonWhitespaceIndex(code, i + 1); - if (i === -1) return -1; + if (statement.type === "BlockStatement") { + return extractBlockRevalidate(statement); } - if (!/[A-Za-z_$]/.test(code[i] ?? "")) return -1; - - const nameStart = i; - while (/[A-Za-z0-9_$]/.test(code[i] ?? "")) i++; - const name = code.slice(nameStart, i); + if (statement.type === "IfStatement") { + return ( + extractStatementRevalidate(statement.consequent) ?? + (statement.alternate ? extractStatementRevalidate(statement.alternate) : null) + ); + } if ( - name === "if" || - name === "for" || - name === "while" || - name === "switch" || - name === "catch" || - name === "function" || - name === "return" || - name === "const" || - name === "let" || - name === "var" || - name === "new" + statement.type === "ForStatement" || + statement.type === "ForInStatement" || + statement.type === "ForOfStatement" || + statement.type === "WhileStatement" || + statement.type === "DoWhileStatement" || + statement.type === "WithStatement" || + statement.type === "LabeledStatement" ) { - return -1; + return extractStatementRevalidate(statement.body); } - 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 (statement.type === "SwitchStatement") { + for (const switchCase of statement.cases) { + for (const consequent of switchCase.consequent) { + const result = extractStatementRevalidate(consequent); + if (result !== null) return result; + } } + return null; } - 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; + if (statement.type === "TryStatement") { + return ( + extractBlockRevalidate(statement.block) ?? + (statement.handler ? extractBlockRevalidate(statement.handler.body) : null) ?? + (statement.finalizer ? extractBlockRevalidate(statement.finalizer) : null) + ); } - 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, "(", ")"); + return null; } -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++; +function extractObjectRevalidate(object: ObjectExpression): number | false | null { + for (const property of object.properties) { + if ( + property.type !== "Property" || + property.computed || + propertyName(property.key) !== "revalidate" + ) { continue; } - if (char === closeToken) { - depth--; - if (depth === 0) return i; - } + return extractStaticNumberValue(property.value); } - return -1; + return null; +} + +function propertyName(key: PropertyKey): string | null { + if (key.type === "Identifier") return key.name; + if (key.type === "Literal" && typeof key.value === "string") return key.value; + return null; } // ─── Layout segment config classification ──────────────────────────────────── @@ -643,7 +470,8 @@ function findMatchingToken( * (`revalidate = 0` → dynamic, `revalidate = Infinity` → static) are decisive. */ export function classifyLayoutSegmentConfig(code: string): LayoutBuildClassification { - const dynamicValue = extractExportConstString(code, "dynamic"); + const program = parseRouteModule(code); + const dynamicValue = program ? extractExportConstStringFromProgram(program, "dynamic") : null; if (dynamicValue === "force-dynamic") { return { kind: "dynamic", @@ -657,7 +485,9 @@ export function classifyLayoutSegmentConfig(code: string): LayoutBuildClassifica }; } - const revalidateValue = extractExportConstNumber(code, "revalidate"); + const revalidateValue = program + ? extractExportConstNumberFromProgram(program, "revalidate") + : null; if (revalidateValue === Infinity) { return { kind: "static", @@ -699,12 +529,14 @@ export function classifyPagesRoute(filePath: string): { return { type: "unknown" }; } - if (hasNamedExport(code, "getServerSideProps")) { + const program = parseRouteModule(code); + + if (program && hasNamedExportInProgram(program, "getServerSideProps")) { return { type: "ssr" }; } - if (hasNamedExport(code, "getStaticProps")) { - const revalidate = extractGetStaticPropsRevalidate(code); + if (program && hasNamedExportInProgram(program, "getStaticProps")) { + const revalidate = extractGetStaticPropsRevalidateFromProgram(program, code); if (revalidate === null || revalidate === false || revalidate === Infinity) { return { type: "static" }; @@ -746,8 +578,10 @@ export function classifyAppRoute( return { type: "unknown" }; } + const program = parseRouteModule(code); + // Check `export const dynamic` - const dynamicValue = extractExportConstString(code, "dynamic"); + const dynamicValue = program ? extractExportConstStringFromProgram(program, "dynamic") : null; if (dynamicValue === "force-dynamic") { return { type: "ssr" }; } @@ -758,7 +592,9 @@ export function classifyAppRoute( } // Check `export const revalidate` - const revalidateValue = extractExportConstNumber(code, "revalidate"); + const revalidateValue = program + ? extractExportConstNumberFromProgram(program, "revalidate") + : null; if (revalidateValue !== null) { if (revalidateValue === Infinity) return { type: "static" }; if (revalidateValue === 0) return { type: "ssr" }; diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index e64efb2a7..fac7702f8 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -14,7 +14,6 @@ */ import vinext from "./index.js"; -import { printBuildReport } from "./build/report.js"; import { runPrerender } from "./build/run-prerender.js"; import path from "node:path"; import fs from "node:fs"; @@ -591,6 +590,7 @@ async function buildApp() { // Opt-in via --precompress CLI flag or `precompress: true` in plugin options. process.stdout.write("\x1b[0m"); + const { printBuildReport } = await import("./build/report.js"); await printBuildReport({ root: process.cwd(), pageExtensions: resolvedNextConfig.pageExtensions, diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index f753da79a..987e0276a 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -1,7 +1,7 @@ /** * Build report tests — verifies route classification, formatting, and sorting. * - * Tests the regex-based export detection helpers and the classification + * Tests the static export detection helpers and the classification * logic for both Pages Router and App Router routes, using real fixture files * where integration testing is needed. */ @@ -58,6 +58,10 @@ describe("hasNamedExport", () => { expect(hasNamedExport("export { getStaticProps as gsp };", "getStaticProps")).toBe(true); }); + it("does not detect alias exported under the searched name", () => { + expect(hasNamedExport("export { gsp as getStaticProps };", "getStaticProps")).toBe(false); + }); + it("returns false when export is absent", () => { expect(hasNamedExport("export default function Page() {}", "getStaticProps")).toBe(false); }); @@ -77,6 +81,13 @@ describe("hasNamedExport", () => { it("detects TypeScript-annotated const", () => { expect(hasNamedExport("export const dynamic: string = 'force-dynamic';", "dynamic")).toBe(true); }); + + it("ignores export-shaped text inside block comments", () => { + const code = `/* +export function getServerSideProps() {} +*/`; + expect(hasNamedExport(code, "getServerSideProps")).toBe(false); + }); }); // ─── extractExportConstString ───────────────────────────────────────────────── @@ -100,6 +111,19 @@ describe("extractExportConstString", () => { ); }); + it("extracts no-substitution template literal values", () => { + expect(extractExportConstString("export const dynamic = `force-dynamic`;", "dynamic")).toBe( + "force-dynamic", + ); + }); + + it("ignores export-shaped string values inside block comments", () => { + const code = `/* +export const dynamic = "force-dynamic"; +*/`; + expect(extractExportConstString(code, "dynamic")).toBeNull(); + }); + it("returns null when export is absent", () => { expect(extractExportConstString("export const revalidate = 60;", "dynamic")).toBeNull(); }); @@ -142,6 +166,16 @@ describe("extractExportConstNumber", () => { ); }); + it("extracts numeric separators", () => { + expect(extractExportConstNumber("export const revalidate = 60_000;", "revalidate")).toBe(60000); + }); + + it("extracts config from TypeScript files with generic arrow syntax", () => { + const code = `const identity = (value: T) => value; +export const revalidate = 60;`; + expect(extractExportConstNumber(code, "revalidate")).toBe(60); + }); + it("returns null when export is absent", () => { expect(extractExportConstNumber("export const dynamic = 'auto';", "revalidate")).toBeNull(); }); @@ -324,12 +358,27 @@ export { getStaticProps } from "./shared"; expect(extractGetStaticPropsRevalidate(code)).toBeNull(); }); + it("ignores an alias exported under the getStaticProps name", () => { + const code = `const gsp = async () => ({ props: {}, revalidate: 60 }); + +export { gsp as getStaticProps }; +`; + 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 from numeric separators in getStaticProps", () => { + const code = `export async function getStaticProps() { + return { props: {}, revalidate: 60_000 }; +}`; + expect(extractGetStaticPropsRevalidate(code)).toBe(60000); + }); }); // ─── classifyPagesRoute (integration — real fixture files) ────────────────────