feat(shadcn): middleware to proxy (#8555)

* feat: implement getFrameworkVersion

* feat(shadcn): add transformNext transformer

* feat(shadcn): rename

* chore: update

* chore: changeset

* fix

* fix: small refactor
This commit is contained in:
shadcn
2025-10-23 22:00:55 +04:00
committed by GitHub
parent 6bddba986d
commit d7e0dc3ec8
6 changed files with 680 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
rename middleware to proxy for Next.js 16

View File

@@ -18,6 +18,7 @@ export type ProjectInfo = {
tailwindConfigFile: string | null
tailwindCssFile: string | null
tailwindVersion: TailwindVersion
frameworkVersion: string | null
aliasPrefix: string | null
}
@@ -75,6 +76,7 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
tailwindConfigFile,
tailwindCssFile,
tailwindVersion,
frameworkVersion: null,
aliasPrefix,
}
@@ -84,6 +86,10 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
? FRAMEWORKS["next-app"]
: FRAMEWORKS["next-pages"]
type.isRSC = isUsingAppDir
type.frameworkVersion = await getFrameworkVersion(
type.framework,
packageJson
)
return type
}
@@ -165,6 +171,42 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
return type
}
export async function getFrameworkVersion(
framework: Framework,
packageJson: ReturnType<typeof getPackageInfo>
) {
if (!packageJson) {
return null
}
// Only detect Next.js version for now.
if (!["next-app", "next-pages"].includes(framework.name)) {
return null
}
const version =
packageJson.dependencies?.next || packageJson.devDependencies?.next
if (!version) {
return null
}
// Extract full semver (major.minor.patch), handling ^, ~, etc.
const versionMatch = version.match(/^[\^~]?(\d+\.\d+\.\d+)/)
if (versionMatch) {
return versionMatch[1] // e.g., "16.0.0"
}
// For ranges like ">=15.0.0 <16.0.0", extract the first version.
const rangeMatch = version.match(/(\d+\.\d+\.\d+)/)
if (rangeMatch) {
return rangeMatch[1]
}
// For "latest", "canary", "rc", etc., return the tag as-is.
return version
}
export async function getTailwindVersion(
cwd: string
): Promise<ProjectInfo["tailwindVersion"]> {

View File

@@ -0,0 +1,424 @@
import { FRAMEWORKS } from "@/src/utils/frameworks"
import { type Config } from "@/src/utils/get-config"
import { transformNext } from "@/src/utils/transformers/transform-next"
import { describe, expect, test, vi } from "vitest"
import { transform } from "../transformers"
const testConfig: Config = {
style: "new-york",
tsx: true,
rsc: true,
tailwind: {
baseColor: "neutral",
cssVariables: true,
config: "tailwind.config.ts",
css: "tailwind.css",
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
resolvedPaths: {
cwd: "/test-project",
components: "/test-project/components",
utils: "/test-project/lib/utils",
ui: "/test-project/ui",
lib: "/test-project/lib",
hooks: "/test-project/hooks",
tailwindConfig: "tailwind.config.ts",
tailwindCss: "tailwind.css",
},
}
vi.mock("@/src/utils/get-project-info", () => ({
getProjectInfo: vi.fn(),
}))
describe("transformNext", () => {
describe("Next.js 16+ transformations", () => {
test("should transform function declaration export", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["next-app"],
frameworkVersion: "16.0.0",
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
expect(
await transform(
{
filename: "middleware.ts",
raw: `import { NextResponse } from "next/server"
export function middleware(request: Request) {
return NextResponse.next()
}`,
config: testConfig,
},
[transformNext]
)
).toMatchInlineSnapshot(`
"import { NextResponse } from "next/server"
export function proxy(request: Request) {
return NextResponse.next()
}"
`)
})
test("should transform async function declaration", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["next-app"],
frameworkVersion: "16.1.0",
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
expect(
await transform(
{
filename: "middleware.ts",
raw: `import { NextResponse } from "next/server"
export async function middleware(request: Request) {
return NextResponse.next()
}`,
config: testConfig,
},
[transformNext]
)
).toMatchInlineSnapshot(`
"import { NextResponse } from "next/server"
export async function proxy(request: Request) {
return NextResponse.next()
}"
`)
})
test("should transform const arrow function export", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["next-app"],
frameworkVersion: "16.0.0",
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
expect(
await transform(
{
filename: "middleware.ts",
raw: `import { NextResponse } from "next/server"
export const middleware = (request: Request) => {
return NextResponse.next()
}`,
config: testConfig,
},
[transformNext]
)
).toMatchInlineSnapshot(`
"import { NextResponse } from "next/server"
export const proxy = (request: Request) => {
return NextResponse.next()
}"
`)
})
test("should transform named export with alias", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["next-app"],
frameworkVersion: "16.0.0",
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
expect(
await transform(
{
filename: "middleware.ts",
raw: `import { NextResponse } from "next/server"
function handler(request: Request) {
return NextResponse.next()
}
export { handler as middleware }`,
config: testConfig,
},
[transformNext]
)
).toMatchInlineSnapshot(`
"import { NextResponse } from "next/server"
function handler(request: Request) {
return NextResponse.next()
}
export { handler as proxy }"
`)
})
})
describe("Next.js < 16 or unknown versions (no transformation)", () => {
test("should not transform for Next.js 15", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["next-app"],
frameworkVersion: "15.0.0",
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
const input = `import { NextResponse } from "next/server"
export function middleware(request: Request) {
return NextResponse.next()
}`
expect(
await transform(
{
filename: "middleware.ts",
raw: input,
config: testConfig,
},
[] // Don't include transformNext for Next.js 15
)
).toBe(input)
})
test("should not transform when frameworkVersion is null", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["next-app"],
frameworkVersion: null,
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
const input = `import { NextResponse } from "next/server"
export function middleware(request: Request) {
return NextResponse.next()
}`
expect(
await transform(
{
filename: "middleware.ts",
raw: input,
config: testConfig,
},
[] // Don't include transformNext when frameworkVersion is null
)
).toBe(input)
})
test("should not transform for canary tag (unknown version)", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["next-app"],
frameworkVersion: "canary",
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
const input = `import { NextResponse } from "next/server"
export function middleware(request: Request) {
return NextResponse.next()
}`
expect(
await transform(
{
filename: "middleware.ts",
raw: input,
config: testConfig,
},
[] // Don't include transformNext for canary tag
)
).toBe(input)
})
test("should not transform for latest tag (unknown version)", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["next-app"],
frameworkVersion: "latest",
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
const input = `import { NextResponse } from "next/server"
export function middleware(request: Request) {
return NextResponse.next()
}`
expect(
await transform(
{
filename: "middleware.ts",
raw: input,
config: testConfig,
},
[] // Don't include transformNext for latest tag
)
).toBe(input)
})
})
describe("Non-middleware files", () => {
test("should not transform non-middleware files", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["next-app"],
frameworkVersion: "16.0.0",
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
const input = `export function middleware() {
return "not a middleware file"
}`
expect(
await transform(
{
filename: "utils.ts",
raw: input,
config: testConfig,
},
[] // Don't include transformNext for non-middleware files
)
).toBe(input)
})
test("should not transform nested middleware files", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["next-app"],
frameworkVersion: "16.0.0",
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
const input = `export function middleware() {
return "nested middleware"
}`
// Nested middleware files should not be transformed
expect(
await transform(
{
filename: "lib/middleware.ts",
raw: input,
config: testConfig,
},
[] // Don't include transformNext for nested middleware files
)
).toBe(input)
expect(
await transform(
{
filename: "lib/supabase/middleware.ts",
raw: input,
config: testConfig,
},
[] // Don't include transformNext for nested middleware files
)
).toBe(input)
})
})
describe("Non-Next.js projects", () => {
test("should not transform for Vite projects", async () => {
const { getProjectInfo } = await import("@/src/utils/get-project-info")
vi.mocked(getProjectInfo).mockResolvedValue({
framework: FRAMEWORKS["vite"],
frameworkVersion: null,
isSrcDir: false,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: null,
tailwindVersion: "v4",
aliasPrefix: "@",
})
const input = `export function middleware() {
return "some middleware"
}`
expect(
await transform(
{
filename: "middleware.ts",
raw: input,
config: testConfig,
},
[] // Don't include transformNext for non-Next.js projects
)
).toBe(input)
})
})
})

View File

@@ -0,0 +1,33 @@
import { Transformer } from "@/src/utils/transformers"
export const transformNext: Transformer = async ({ sourceFile }) => {
// export function middleware.
sourceFile.getFunctions().forEach((func) => {
if (func.getName() === "middleware") {
func.rename("proxy")
}
})
// export const middleware.
sourceFile.getVariableDeclarations().forEach((variable) => {
if (variable.getName() === "middleware") {
variable.rename("proxy")
}
})
// export { handler as middleware }.
sourceFile.getExportDeclarations().forEach((exportDecl) => {
const namedExports = exportDecl.getNamedExports()
namedExports.forEach((namedExport) => {
if (namedExport.getName() === "middleware") {
namedExport.setName("proxy")
}
const aliasNode = namedExport.getAliasNode()
if (aliasNode?.getText() === "middleware") {
namedExport.setAlias("proxy")
}
})
})
return sourceFile
}

View File

@@ -21,6 +21,7 @@ import { transform } from "@/src/utils/transformers"
import { transformCssVars } from "@/src/utils/transformers/transform-css-vars"
import { transformIcons } from "@/src/utils/transformers/transform-icons"
import { transformImport } from "@/src/utils/transformers/transform-import"
import { transformNext } from "@/src/utils/transformers/transform-next"
import { transformRsc } from "@/src/utils/transformers/transform-rsc"
import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix"
import prompts from "prompts"
@@ -138,6 +139,9 @@ export async function updateFiles(
transformCssVars,
transformTwPrefixes,
transformIcons,
...(_isNext16Middleware(filePath, projectInfo, config)
? [transformNext]
: []),
]
)
@@ -186,6 +190,11 @@ export async function updateFiles(
}
}
// Rename middleware.ts to proxy.ts for Next.js 16+.
if (_isNext16Middleware(filePath, projectInfo, config)) {
filePath = filePath.replace(/middleware\.(ts|js)$/, "proxy.$1")
}
// Create the target directory if it doesn't exist.
if (!existsSync(targetDir)) {
await fs.mkdir(targetDir, { recursive: true })
@@ -715,3 +724,26 @@ export function toAliasedImport(
// but usually config.aliases already include it.
return `${aliasBase}${suffix}${keepExt}`
}
function _isNext16Middleware(
filePath: string,
projectInfo: ProjectInfo | null,
config: Config
) {
const isRootMiddleware =
filePath === path.join(config.resolvedPaths.cwd, "middleware.ts") ||
filePath === path.join(config.resolvedPaths.cwd, "middleware.js")
const isNextJs =
projectInfo?.framework.name === "next-app" ||
projectInfo?.framework.name === "next-pages"
if (!isRootMiddleware || !isNextJs || !projectInfo?.frameworkVersion) {
return false
}
const majorVersion = parseInt(projectInfo.frameworkVersion.split(".")[0])
const isNext16Plus = !isNaN(majorVersion) && majorVersion >= 16
return isNext16Plus
}

View File

@@ -2,7 +2,10 @@ import path from "path"
import { describe, expect, test } from "vitest"
import { FRAMEWORKS } from "../../src/utils/frameworks"
import { getProjectInfo } from "../../src/utils/get-project-info"
import {
getFrameworkVersion,
getProjectInfo,
} from "../../src/utils/get-project-info"
describe("get project info", async () => {
test.each([
@@ -16,6 +19,7 @@ describe("get project info", async () => {
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "app/globals.css",
tailwindVersion: "v3",
frameworkVersion: null,
aliasPrefix: "@",
},
},
@@ -29,6 +33,7 @@ describe("get project info", async () => {
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "src/app/styles.css",
tailwindVersion: "v3",
frameworkVersion: null,
aliasPrefix: "#",
},
},
@@ -42,6 +47,7 @@ describe("get project info", async () => {
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "styles/globals.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "~",
},
},
@@ -55,6 +61,7 @@ describe("get project info", async () => {
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "src/styles/globals.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "@",
},
},
@@ -68,6 +75,7 @@ describe("get project info", async () => {
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "src/styles/globals.css",
tailwindVersion: "v3",
frameworkVersion: "14.2.4",
aliasPrefix: "~",
},
},
@@ -81,6 +89,7 @@ describe("get project info", async () => {
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "src/styles/globals.css",
tailwindVersion: "v3",
frameworkVersion: "13.4.2",
aliasPrefix: "~",
},
},
@@ -94,6 +103,7 @@ describe("get project info", async () => {
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "app/tailwind.css",
tailwindVersion: "v3",
frameworkVersion: null,
aliasPrefix: "~",
},
},
@@ -107,6 +117,7 @@ describe("get project info", async () => {
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "app/tailwind.css",
tailwindVersion: "v3",
frameworkVersion: null,
aliasPrefix: "~",
},
},
@@ -120,6 +131,7 @@ describe("get project info", async () => {
tailwindConfigFile: "tailwind.config.js",
tailwindCssFile: "src/index.css",
tailwindVersion: "v3",
frameworkVersion: null,
aliasPrefix: null,
},
},
@@ -131,3 +143,134 @@ describe("get project info", async () => {
).toStrictEqual(type)
})
})
describe("getFrameworkVersion", () => {
describe("Next.js version detection", () => {
test.each([
{
name: "exact semver",
input: "16.0.0",
framework: "next-app",
expected: "16.0.0",
},
{
name: "caret prefix",
input: "^16.1.2",
framework: "next-app",
expected: "16.1.2",
},
{
name: "tilde prefix",
input: "~15.0.3",
framework: "next-app",
expected: "15.0.3",
},
{
name: "version range",
input: ">=15.0.0 <16.0.0",
framework: "next-app",
expected: "15.0.0",
},
{
name: "latest tag",
input: "latest",
framework: "next-app",
expected: "latest",
},
{
name: "canary tag",
input: "canary",
framework: "next-app",
expected: "canary",
},
{
name: "rc tag",
input: "rc",
framework: "next-app",
expected: "rc",
},
])(
`should extract $name ($input) -> $expected`,
async ({ input, framework, expected }) => {
const packageJson = {
dependencies: {
next: input,
},
}
const version = await getFrameworkVersion(
FRAMEWORKS[framework as keyof typeof FRAMEWORKS],
packageJson
)
expect(version).toBe(expected)
}
)
test("should handle version in devDependencies", async () => {
const packageJson = {
devDependencies: {
next: "16.0.0",
},
}
const version = await getFrameworkVersion(
FRAMEWORKS["next-pages"],
packageJson
)
expect(version).toBe("16.0.0")
})
test("should return null when next is not in dependencies", async () => {
const packageJson = {
dependencies: {
react: "^18.0.0",
},
}
const version = await getFrameworkVersion(
FRAMEWORKS["next-app"],
packageJson
)
expect(version).toBe(null)
})
test("should return null when packageJson is null", async () => {
const version = await getFrameworkVersion(FRAMEWORKS["next-app"], null)
expect(version).toBe(null)
})
})
describe("Other frameworks", () => {
test.each([
{
name: "Vite",
framework: "vite",
package: "vite",
version: "^5.0.0",
},
{
name: "Remix",
framework: "remix",
package: "@remix-run/react",
version: "^2.0.0",
},
{
name: "Astro",
framework: "astro",
package: "astro",
version: "^4.0.0",
},
])(
`should return null for $name`,
async ({ framework, package: pkg, version: ver }) => {
const packageJson = {
dependencies: {
[pkg]: ver,
},
}
const version = await getFrameworkVersion(
FRAMEWORKS[framework as keyof typeof FRAMEWORKS],
packageJson
)
expect(version).toBe(null)
}
)
})
})