diff --git a/.changeset/resolve-hoisted-next-docs.md b/.changeset/resolve-hoisted-next-docs.md new file mode 100644 index 0000000..8d01247 --- /dev/null +++ b/.changeset/resolve-hoisted-next-docs.md @@ -0,0 +1,5 @@ +--- +"next-devtools-mcp": patch +--- + +Resolve the installed Next.js package from the project so hoisted dependencies report the correct version and absolute documentation path. diff --git a/README.md b/README.md index ce6921d..f0fc7a5 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ Output: JSON with the tool's result. Does **not** fetch docs. Recent Next.js releases bundle their docs (markdown, matching your installed version) at `node_modules/next/dist/docs/`. This tool checks for those files before returning reading instructions. If an installed release has no bundled docs (including early 16.x releases), it offers https://nextjs.org/docs as a fallback and asks the agent to verify APIs against the installed version. Missing dependencies receive installation guidance instead. On Next.js below 16, it recommends `npx @next/codemod@latest upgrade latest`. -Input: `topic` (optional), `project_path` (optional, defaults to cwd). +Input: `topic` (optional), `project_path` (optional, defaults to cwd). The tool resolves `next/package.json` from that project, including hoisted workspace dependencies, and returns an absolute `docsPath` for the installed package. diff --git a/src/tools/nextjs-docs.ts b/src/tools/nextjs-docs.ts index 1067a60..943d719 100644 --- a/src/tools/nextjs-docs.ts +++ b/src/tools/nextjs-docs.ts @@ -1,6 +1,7 @@ import { z } from "zod" import fs from "node:fs" import path from "node:path" +import { createRequire } from "node:module" // Older 16.x releases do not bundle docs. Check package contents before // directing agents to local files; the major version alone is insufficient. @@ -47,6 +48,24 @@ function parseMajor(versionish: string | null | undefined): number | null { return parseInt(match[1], 10) } +// Follow the project's module resolution, including hoisted workspace installs. +function resolveNextPackagePath(projectPath: string): string | null { + try { + return createRequire(path.resolve(projectPath, "package.json")).resolve("next/package.json") + } catch { + return null + } +} + +function getDocsDirectory(projectPath: string): string { + const packagePath = resolveNextPackagePath(projectPath) + return path.join( + packagePath ? path.dirname(packagePath) : path.resolve(projectPath, "node_modules", "next"), + "dist", + "docs" + ) +} + // Resolve the Next.js version for a project, preferring the actually-installed // version (most accurate) over the declared dependency range. function resolveNextVersion(projectPath: string): { @@ -54,13 +73,8 @@ function resolveNextVersion(projectPath: string): { source: "installed" | "declared" | null } { try { - const installedPkg = path.join( - projectPath, - "node_modules", - "next", - "package.json" - ) - if (fs.existsSync(installedPkg)) { + const installedPkg = resolveNextPackagePath(projectPath) + if (installedPkg) { const { version } = JSON.parse(fs.readFileSync(installedPkg, "utf8")) if (typeof version === "string") return { version, source: "installed" } } @@ -94,7 +108,7 @@ export async function handler({ topic, project_path }: NextjsDocsArgs): Promise< : /latest|canary|rc|beta/i.test(version ?? "") if (isModern) { - const docsDir = path.join(projectPath, "node_modules", "next", "dist", "docs") + const docsDir = getDocsDirectory(projectPath) const docsExist = fs.existsSync(docsDir) if (!docsExist) { const installed = source === "installed" @@ -119,13 +133,13 @@ export async function handler({ topic, project_path }: NextjsDocsArgs): Promise< status: "use_bundled_docs", nextVersion: version, versionSource: source, - docsPath: "node_modules/next/dist/docs/", + docsPath: docsDir, docsAvailable: docsExist, instructions: [ "Next.js ships its full documentation with the installed package, matching your exact version.", `Read the relevant guide directly from \`${docsDir}\` (markdown files mirroring the nextjs.org/docs structure).`, topic - ? `For "${topic}", search those files, e.g.: grep -ril "${topic.replace(/"/g, "")}" node_modules/next/dist/docs` + ? `For "${topic}", search the markdown files under \`${docsDir}\` for that API or topic.` : "Browse the directory or grep it for the API/topic you need.", "Do not rely on training-data knowledge of Next.js APIs — this version may differ. Prefer the bundled docs.", ], diff --git a/test/unit/nextjs-docs-gateway.test.ts b/test/unit/nextjs-docs-gateway.test.ts index 0a36ecb..b243ae6 100644 --- a/test/unit/nextjs-docs-gateway.test.ts +++ b/test/unit/nextjs-docs-gateway.test.ts @@ -51,7 +51,7 @@ describe("nextjs_docs gateway", () => { expect(result.nextVersion).toBe("16.3.0") expect(result.versionSource).toBe("installed") expect(result.docsAvailable).toBe(true) - expect(result.docsPath).toBe("node_modules/next/dist/docs/") + expect(result.docsPath).toBe(fs.realpathSync(path.join(tmpDir, "node_modules/next/dist/docs"))) }) it("treats a canary install as modern", async () => { @@ -100,7 +100,7 @@ describe("nextjs_docs gateway", () => { expect(JSON.stringify(result.instructions)).toContain("16.0.7") }) - it("includes a grep hint when a topic is provided", async () => { + it("includes a search hint when a topic is provided", async () => { tmpDir = makeProject({ installed: "16.2.0", withDocs: true }) const result = JSON.parse(await handler({ project_path: tmpDir, topic: "use cache" })) expect(JSON.stringify(result.instructions)).toContain("use cache") @@ -119,3 +119,42 @@ it("asks for installation only when a modern dependency is not installed", async fs.rmSync(dir, { recursive: true, force: true }) } }) + +it("resolves installed versions and docs from a hoisted dependency", async () => { + const dir = makeProject({ installed: "16.3.0", withDocs: true }) + const child = path.join(dir, "packages", "web") + fs.mkdirSync(child, { recursive: true }) + fs.writeFileSync( + path.join(child, "package.json"), + JSON.stringify({ dependencies: { next: "^16.0.0" } }) + ) + try { + const result = JSON.parse(await handler({ project_path: child, topic: "use cache" })) + expect(result.versionSource).toBe("installed") + expect(result.nextVersion).toBe("16.3.0") + expect(result.docsAvailable).toBe(true) + expect(result.docsPath).toBe(fs.realpathSync(path.join(dir, "node_modules/next/dist/docs"))) + expect(result.instructions[1]).toContain(result.docsPath) + expect(result.instructions[2]).toContain(result.docsPath) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +it("prefers a child-local installation over an ancestor installation", async () => { + const dir = makeProject({ installed: "16.3.0", withDocs: true }) + const child = path.join(dir, "packages", "web") + const pkg = path.join(child, "node_modules", "next") + fs.mkdirSync(path.join(pkg, "dist", "docs"), { recursive: true }) + fs.writeFileSync( + path.join(pkg, "package.json"), + JSON.stringify({ name: "next", version: "16.2.0" }) + ) + try { + const result = JSON.parse(await handler({ project_path: child })) + expect(result.nextVersion).toBe("16.2.0") + expect(result.docsPath).toBe(fs.realpathSync(path.join(pkg, "dist", "docs"))) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +})