diff --git a/scripts/fixtures/nextjs-pages/app/about/page.tsx b/scripts/fixtures/nextjs-pages/app/about/page.tsx new file mode 100644 index 0000000..45e95ff --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/about/page.tsx @@ -0,0 +1,3 @@ +export default function About() { + return
About
; +} diff --git a/scripts/fixtures/nextjs-pages/app/api/users/route.ts b/scripts/fixtures/nextjs-pages/app/api/users/route.ts new file mode 100644 index 0000000..c6a2c99 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/api/users/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ users: [] }); +} diff --git a/scripts/fixtures/nextjs-pages/app/blog/[slug]/page.tsx b/scripts/fixtures/nextjs-pages/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..a155be1 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/blog/[slug]/page.tsx @@ -0,0 +1,3 @@ +export default function BlogPost({ params }: { params: { slug: string } }) { + return
Blog: {params.slug}
; +} diff --git a/scripts/fixtures/nextjs-pages/app/dashboard/settings/page.tsx b/scripts/fixtures/nextjs-pages/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..bfd46a3 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/dashboard/settings/page.tsx @@ -0,0 +1,3 @@ +export default function Settings() { + return
Settings
; +} diff --git a/scripts/fixtures/nextjs-pages/app/default.tsx b/scripts/fixtures/nextjs-pages/app/default.tsx new file mode 100644 index 0000000..ca4070f --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return
Default
; +} diff --git a/scripts/fixtures/nextjs-pages/app/error.tsx b/scripts/fixtures/nextjs-pages/app/error.tsx new file mode 100644 index 0000000..63a13bc --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/error.tsx @@ -0,0 +1,4 @@ +"use client"; +export default function Error() { + return
Error
; +} diff --git a/scripts/fixtures/nextjs-pages/app/layout.tsx b/scripts/fixtures/nextjs-pages/app/layout.tsx new file mode 100644 index 0000000..b3b059d --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/layout.tsx @@ -0,0 +1,3 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/scripts/fixtures/nextjs-pages/app/loading.tsx b/scripts/fixtures/nextjs-pages/app/loading.tsx new file mode 100644 index 0000000..fc80ef0 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return
Loading...
; +} diff --git a/scripts/fixtures/nextjs-pages/app/not-found.tsx b/scripts/fixtures/nextjs-pages/app/not-found.tsx new file mode 100644 index 0000000..6ebeead --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/not-found.tsx @@ -0,0 +1,3 @@ +export default function NotFound() { + return
Not found
; +} diff --git a/scripts/fixtures/nextjs-pages/app/page.tsx b/scripts/fixtures/nextjs-pages/app/page.tsx new file mode 100644 index 0000000..aa58832 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/page.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return
Home
; +} diff --git a/scripts/fixtures/nextjs-pages/app/template.tsx b/scripts/fixtures/nextjs-pages/app/template.tsx new file mode 100644 index 0000000..ae175b8 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/app/template.tsx @@ -0,0 +1,3 @@ +export default function Template({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/scripts/fixtures/nextjs-pages/next.config.js b/scripts/fixtures/nextjs-pages/next.config.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/scripts/fixtures/nextjs-pages/next.config.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/scripts/fixtures/nextjs-pages/package.json b/scripts/fixtures/nextjs-pages/package.json new file mode 100644 index 0000000..bf5b917 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/package.json @@ -0,0 +1,7 @@ +{ + "name": "nextjs-pages-fixture", + "dependencies": { + "next": "14.0.0", + "react": "18.0.0" + } +} diff --git a/scripts/fixtures/nextjs-pages/pages/_app.tsx b/scripts/fixtures/nextjs-pages/pages/_app.tsx new file mode 100644 index 0000000..39b86cd --- /dev/null +++ b/scripts/fixtures/nextjs-pages/pages/_app.tsx @@ -0,0 +1,3 @@ +export default function App({ Component, pageProps }) { + return ; +} diff --git a/scripts/fixtures/nextjs-pages/pages/_document.tsx b/scripts/fixtures/nextjs-pages/pages/_document.tsx new file mode 100644 index 0000000..4435f49 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/pages/_document.tsx @@ -0,0 +1,4 @@ +import { Html, Head, Main, NextScript } from "next/document"; +export default function Document() { + return (
); +} diff --git a/scripts/fixtures/nextjs-pages/pages/about/index.tsx b/scripts/fixtures/nextjs-pages/pages/about/index.tsx new file mode 100644 index 0000000..45e95ff --- /dev/null +++ b/scripts/fixtures/nextjs-pages/pages/about/index.tsx @@ -0,0 +1,3 @@ +export default function About() { + return
About
; +} diff --git a/scripts/fixtures/nextjs-pages/pages/api/health.ts b/scripts/fixtures/nextjs-pages/pages/api/health.ts new file mode 100644 index 0000000..67c99d5 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/pages/api/health.ts @@ -0,0 +1,3 @@ +export default function handler(req, res) { + res.status(200).json({ ok: true }); +} diff --git a/scripts/fixtures/nextjs-pages/pages/blog/[slug].tsx b/scripts/fixtures/nextjs-pages/pages/blog/[slug].tsx new file mode 100644 index 0000000..577fea6 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/pages/blog/[slug].tsx @@ -0,0 +1,3 @@ +export default function BlogPost() { + return
Blog Post
; +} diff --git a/scripts/fixtures/nextjs-pages/pages/index.tsx b/scripts/fixtures/nextjs-pages/pages/index.tsx new file mode 100644 index 0000000..aa58832 --- /dev/null +++ b/scripts/fixtures/nextjs-pages/pages/index.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return
Home
; +} diff --git a/scripts/fixtures/nextjs-pages/pages/products/index.tsx b/scripts/fixtures/nextjs-pages/pages/products/index.tsx new file mode 100644 index 0000000..b94821f --- /dev/null +++ b/scripts/fixtures/nextjs-pages/pages/products/index.tsx @@ -0,0 +1,3 @@ +export default function Products() { + return
Products
; +} diff --git a/src/extractors/nextjs.test.ts b/src/extractors/nextjs.test.ts new file mode 100644 index 0000000..c83cb22 --- /dev/null +++ b/src/extractors/nextjs.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "path"; +import { nextjs } from "./nextjs.ts"; +import { createScanContext } from "../scan-context.ts"; + +const FIXTURE = resolve(import.meta.dir, "../../scripts/fixtures/nextjs-pages"); + +function extract(fixturePath: string = FIXTURE) { + const ctx = createScanContext(fixturePath); + return nextjs.extract(ctx); +} + +describe("nextjs page route extraction", () => { + const endpoints = extract(); + + // ---- App Router pages ---- + + test("extracts app router root page (/)", () => { + const ep = endpoints.find( + (e) => e.kind === "page" && e.path === "/" && e.file.includes("app/page"), + ); + expect(ep).toBeDefined(); + expect(ep!.method).toBe("GET"); + expect(ep!.handler).toBe("Page"); + expect(ep!.framework).toBe("nextjs"); + }); + + test("extracts app router /about page", () => { + const ep = endpoints.find( + (e) => + e.kind === "page" && e.path === "/about" && e.file.includes("app/"), + ); + expect(ep).toBeDefined(); + expect(ep!.method).toBe("GET"); + }); + + test("extracts app router dynamic [slug] page", () => { + const ep = endpoints.find( + (e) => e.kind === "page" && e.path === "/blog/[slug]", + ); + expect(ep).toBeDefined(); + expect(ep!.method).toBe("GET"); + expect(ep!.params.length).toBeGreaterThan(0); + expect(ep!.params[0]!.name).toBe("slug"); + }); + + test("extracts app router nested page", () => { + const ep = endpoints.find( + (e) => e.kind === "page" && e.path === "/dashboard/settings", + ); + expect(ep).toBeDefined(); + expect(ep!.method).toBe("GET"); + }); + + test("excludes layout.tsx from app router pages", () => { + const layouts = endpoints.filter( + (e) => e.kind === "page" && e.file.includes("layout"), + ); + expect(layouts).toHaveLength(0); + }); + + test("excludes loading.tsx from app router pages", () => { + const loading = endpoints.filter( + (e) => e.kind === "page" && e.file.includes("loading"), + ); + expect(loading).toHaveLength(0); + }); + + test("excludes error.tsx from app router pages", () => { + const errors = endpoints.filter( + (e) => e.kind === "page" && e.file.includes("error"), + ); + expect(errors).toHaveLength(0); + }); + + test("excludes not-found.tsx from app router pages", () => { + const notFounds = endpoints.filter( + (e) => e.kind === "page" && e.file.includes("not-found"), + ); + expect(notFounds).toHaveLength(0); + }); + + test("excludes template.tsx from app router pages", () => { + const templates = endpoints.filter( + (e) => e.kind === "page" && e.file.includes("template"), + ); + expect(templates).toHaveLength(0); + }); + + test("excludes default.tsx from app router pages", () => { + const defaults = endpoints.filter( + (e) => e.kind === "page" && e.file.includes("default"), + ); + expect(defaults).toHaveLength(0); + }); + + // ---- App Router API routes still work ---- + + test("still extracts app router API routes", () => { + const ep = endpoints.find( + (e) => e.kind === "api" && e.path === "/api/users", + ); + expect(ep).toBeDefined(); + expect(ep!.method).toBe("GET"); + expect(ep!.handler).toBe("GET"); + }); + + // ---- Pages Router pages ---- + + test("extracts pages router root page (pages/index.tsx → /)", () => { + const ep = endpoints.find( + (e) => + e.kind === "page" && e.path === "/" && e.file.includes("pages/index"), + ); + expect(ep).toBeDefined(); + expect(ep!.method).toBe("GET"); + expect(ep!.handler).toBe("Page"); + }); + + test("extracts pages router /about (pages/about/index.tsx → /about)", () => { + const ep = endpoints.find( + (e) => + e.kind === "page" && + e.path === "/about" && + e.file.includes("pages/about"), + ); + expect(ep).toBeDefined(); + expect(ep!.method).toBe("GET"); + }); + + test("extracts pages router dynamic route (pages/blog/[slug].tsx → /blog/[slug])", () => { + const ep = endpoints.find( + (e) => + e.kind === "page" && + e.path === "/blog/[slug]" && + e.file.includes("pages/blog"), + ); + expect(ep).toBeDefined(); + expect(ep!.method).toBe("GET"); + expect(ep!.params.length).toBeGreaterThan(0); + expect(ep!.params[0]!.name).toBe("slug"); + }); + + test("extracts pages router /products (pages/products/index.tsx → /products)", () => { + const ep = endpoints.find( + (e) => + e.kind === "page" && + e.path === "/products" && + e.file.includes("pages/products"), + ); + expect(ep).toBeDefined(); + expect(ep!.method).toBe("GET"); + }); + + test("excludes _app.tsx from pages router pages", () => { + const apps = endpoints.filter( + (e) => e.kind === "page" && e.file.includes("_app"), + ); + expect(apps).toHaveLength(0); + }); + + test("excludes _document.tsx from pages router pages", () => { + const docs = endpoints.filter( + (e) => e.kind === "page" && e.file.includes("_document"), + ); + expect(docs).toHaveLength(0); + }); + + test("does not emit pages/api/ files as pages (handled as API)", () => { + const apiPages = endpoints.filter( + (e) => e.kind === "page" && e.path.startsWith("/api"), + ); + expect(apiPages).toHaveLength(0); + }); + + test("still extracts pages/api/ as API endpoints", () => { + const ep = endpoints.find( + (e) => e.kind === "api" && e.path === "/api/health", + ); + expect(ep).toBeDefined(); + expect(ep!.method).toBe("ANY"); + expect(ep!.handler).toBe("default"); + }); + + // ---- Kind validation ---- + + test("all page endpoints have kind=page", () => { + const pages = endpoints.filter((e) => e.handler === "Page"); + expect(pages.length).toBeGreaterThan(0); + for (const p of pages) { + expect(p.kind).toBe("page"); + } + }); + + test("all page endpoints have method=GET", () => { + const pages = endpoints.filter((e) => e.kind === "page"); + for (const p of pages) { + expect(p.method).toBe("GET"); + } + }); +}); diff --git a/src/extractors/nextjs.ts b/src/extractors/nextjs.ts index bd80836..dc6ebef 100644 --- a/src/extractors/nextjs.ts +++ b/src/extractors/nextjs.ts @@ -1,5 +1,5 @@ import { readdirSync, existsSync, lstatSync, realpathSync } from "fs"; -import { join, relative } from "path"; +import { join, relative, basename } from "path"; import type { EndpointInfo, Extractor } from "../types.ts"; import { endpoint, @@ -9,6 +9,23 @@ import { } from "../utils.ts"; import { SKIP_DIRS } from "../scan-context.ts"; +const PAGE_EXTENSIONS = /\.(tsx|jsx|ts|js)$/; +const APP_ROUTER_PAGE_RE = /^page\.(tsx|jsx|ts|js)$/; +const APP_ROUTER_LAYOUT_FILES = new Set([ + "layout", + "template", + "loading", + "error", + "not-found", + "default", +]); +const PAGES_ROUTER_SPECIAL_FILES = new Set([ + "_app", + "_document", + "_error", + "_middleware", +]); + function findNextConfigs(root: string, maxDepth = 3): string[] { const results: string[] = []; function walk(dir: string, depth: number) { @@ -143,6 +160,32 @@ export const nextjs: Extractor = { } } + // App Router: app/**/page.{tsx,jsx,ts,js} + for (const baseName of ["app", join("src", "app")]) { + const base = join(appRoot, baseName); + if (!existsSync(base)) continue; + + for (const pf of walkFiles(base, (n) => APP_ROUTER_PAGE_RE.test(n))) { + ctx.filesScanned++; + const rel = ctx.rel(pf); + const pageDir = pf.replace(/\/page\.(tsx|jsx|ts|js)$/, ""); + const urlPath = normalizePath("/" + relative(base, pageDir)); + + endpoints.push( + endpoint({ + method: "GET", + kind: "page", + path: urlPath, + handler: "Page", + file: rel, + line: 1, + framework: "nextjs", + params: extractPathParams(urlPath), + }), + ); + } + } + // Pages Router: pages/api/**/* for (const baseName of ["pages", join("src", "pages")]) { const pagesApi = join(appRoot, baseName, "api"); @@ -168,6 +211,54 @@ export const nextjs: Extractor = { ); } } + + // Pages Router: pages/**/*.{tsx,jsx,ts,js} (excluding api/ and special files) + for (const baseName of ["pages", join("src", "pages")]) { + const pagesBase = join(appRoot, baseName); + if (!existsSync(pagesBase)) continue; + + for (const pf of walkFiles(pagesBase, (n) => PAGE_EXTENSIONS.test(n))) { + const relToPages = relative(pagesBase, pf); + // Skip pages/api/** — already handled above + if (relToPages.startsWith("api/") || relToPages.startsWith("api\\")) + continue; + + const stem = basename(pf).replace(PAGE_EXTENSIONS, ""); + // Skip special Next.js files + if (PAGES_ROUTER_SPECIAL_FILES.has(stem)) continue; + // Skip layout-like files in pages router + if (APP_ROUTER_LAYOUT_FILES.has(stem)) continue; + + ctx.filesScanned++; + const rel = ctx.rel(pf); + + // Derive URL path: strip extension, normalize /index → / + let urlSegment = relToPages.replace(PAGE_EXTENSIONS, ""); + // Normalize path separators (Windows compat) + urlSegment = urlSegment.replace(/\\/g, "/"); + // /index at any level → parent directory path + if (urlSegment === "index") { + urlSegment = ""; + } else if (urlSegment.endsWith("/index")) { + urlSegment = urlSegment.slice(0, -"/index".length); + } + // Convert [param] to :param for bracket params + const urlPath = normalizePath("/" + urlSegment); + + endpoints.push( + endpoint({ + method: "GET", + kind: "page", + path: urlPath, + handler: "Page", + file: rel, + line: 1, + framework: "nextjs", + params: extractPathParams(urlPath), + }), + ); + } + } } return endpoints; diff --git a/tsconfig.json b/tsconfig.json index bfa0fea..d1ea496 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,5 +25,6 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + "exclude": ["scripts/fixtures"] }