diff --git a/package.json b/package.json index 1cfe07b..15225ea 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "fastify": "^5.3.3", "node-fetch": "^2.6.7", "prettier": "^3.1.0", + "tsconfig-paths": "^4.2.0", "ws": "^8.18.0" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e62bc5d..8b26744 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: prettier: specifier: ^3.1.0 version: 3.6.2 + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 ws: specifier: ^8.18.0 version: 8.18.3 @@ -3438,6 +3441,10 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -7574,6 +7581,12 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + tslib@2.8.1: optional: true diff --git a/src/webpack-plugin.ts b/src/webpack-plugin.ts index c02c5ae..7cf378c 100644 --- a/src/webpack-plugin.ts +++ b/src/webpack-plugin.ts @@ -24,6 +24,7 @@ import type { Compilation, Compiler, Module as WebpackModule } from "webpack"; import { sources } from "webpack"; import fs from "fs"; import path from "path"; +import { loadConfig } from "tsconfig-paths"; interface Asset { name: string; @@ -108,51 +109,52 @@ export default class CodePressWebpackPlugin { } } - // Always try to read @ alias from tsconfig.json if not already present - // resolve.alias usually has Next.js internals but not the @ path alias - if (!aliases.has("@")) { - const tsconfigPath = path.join(compiler.context, "tsconfig.json"); - + // Load path aliases from tsconfig.json using tsconfig-paths library + // This properly handles: + // - JSON with comments (TypeScript's JSON5-like syntax) + // - The "extends" field to resolve inherited configurations + // - Complex path patterns + const tsconfigPath = path.join(compiler.context, "tsconfig.json"); + if (fs.existsSync(tsconfigPath)) { try { - if (fs.existsSync(tsconfigPath)) { - const tsconfigContent = fs.readFileSync(tsconfigPath, "utf8"); - - // Extract paths directly using regex (avoids JSON parsing issues with comments/globs) - // Match: "paths": { "@/*": ["./src/*"] } or similar - const pathsMatch = tsconfigContent.match( - /"paths"\s*:\s*\{([^}]+)\}/ - ); - - if (pathsMatch) { - const pathsContent = pathsMatch[1]; - - // Extract individual path mappings: "@/*": ["./src/*"] - const pathPattern = /"([^"]+)"\s*:\s*\[\s*"([^"]+)"/g; - let match; - while ((match = pathPattern.exec(pathsContent)) !== null) { - const aliasPattern = match[1]; // "@/*" - const targetPattern = match[2]; // "./src/*" - - // Convert "@/*" -> "@" and "./src/*" -> "src" - const alias = aliasPattern.replace(/\/\*$/, ""); - const targetPath = targetPattern - .replace(/^\.\//, "") - .replace(/\/\*$/, ""); + const config = loadConfig(compiler.context); + + if (config.resultType === "success" && config.paths) { + for (const [pattern, targets] of Object.entries(config.paths)) { + // Convert "@/*" -> "@" and "./src/*" -> "src" + const alias = pattern.replace(/\/\*$/, ""); + + // Skip if we already have this alias from webpack config + if (aliases.has(alias)) continue; + + // Get the first target path + const targetPattern = targets[0]; + if (targetPattern) { + // Convert absolute or relative path to relative directory + let targetPath = targetPattern + .replace(/\/\*$/, "") // Remove trailing /* + .replace(/^\.\//, ""); // Remove leading ./ + + // If it's an absolute path, make it relative to project root + if (path.isAbsolute(targetPath)) { + targetPath = path.relative(compiler.context, targetPath); + } aliases.set(alias, targetPath); } } } } catch (e) { - console.warn("[CodePress] Error reading tsconfig.json:", e); + console.warn("[CodePress] Error loading tsconfig.json paths:", e); } + } - // Fallback: Next.js convention is @/* -> ./src/* - if (!aliases.has("@")) { - const srcDir = path.join(compiler.context, "src"); - if (fs.existsSync(srcDir)) { - aliases.set("@", "src"); - } + // Fallback: Next.js convention is @/* -> ./src/* + // Only applies if no @ alias was configured + if (!aliases.has("@")) { + const srcDir = path.join(compiler.context, "src"); + if (fs.existsSync(srcDir)) { + aliases.set("@", "src"); } } diff --git a/test/webpack-plugin.test.ts b/test/webpack-plugin.test.ts new file mode 100644 index 0000000..f9e7d07 --- /dev/null +++ b/test/webpack-plugin.test.ts @@ -0,0 +1,402 @@ +/** + * Tests for CodePressWebpackPlugin's tsconfig.json path resolution + * + * These tests verify that the plugin correctly parses tsconfig.json paths + * using the tsconfig-paths library, including: + * - Basic path aliases + * - JSON with comments (TypeScript's JSON5-like syntax) + * - Extended tsconfig files (extends field) + * - Fallback to Next.js conventions + */ + +import fs from "fs"; +import path from "path"; +import os from "os"; +import CodePressWebpackPlugin from "../src/webpack-plugin"; + +// Helper to create a temporary directory with test files +function createTempProject(files: Record): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codepress-test-")); + + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(tempDir, filePath); + const dir = path.dirname(fullPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(fullPath, content, "utf8"); + } + + return tempDir; +} + +// Helper to clean up temp directory +function cleanupTempProject(tempDir: string): void { + fs.rmSync(tempDir, { recursive: true, force: true }); +} + +// Create a mock webpack compiler +function createMockCompiler(context: string, resolveAlias?: Record) { + return { + context, + options: { + resolve: { + alias: resolveAlias || {}, + }, + }, + hooks: { + compilation: { tap: jest.fn() }, + thisCompilation: { tap: jest.fn() }, + }, + } as any; +} + +describe("CodePressWebpackPlugin tsconfig parsing", () => { + let tempDir: string; + + afterEach(() => { + if (tempDir) { + cleanupTempProject(tempDir); + } + }); + + describe("basic tsconfig.json paths", () => { + it("should parse simple path aliases", () => { + tempDir = createTempProject({ + "tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./src/*"], + }, + }, + }), + "src/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + // Access private method via any cast for testing + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("src"); + }); + + it("should parse multiple path aliases", () => { + tempDir = createTempProject({ + "tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./src/*"], + "@components/*": ["./src/components/*"], + "@utils/*": ["./src/utils/*"], + }, + }, + }), + "src/index.ts": "", + "src/components/index.ts": "", + "src/utils/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("src"); + expect(aliases.get("@components")).toBe("src/components"); + expect(aliases.get("@utils")).toBe("src/utils"); + }); + }); + + describe("JSON with comments (JSON5-like)", () => { + it("should parse tsconfig with single-line comments", () => { + tempDir = createTempProject({ + "tsconfig.json": `{ + // This is a comment + "compilerOptions": { + "baseUrl": ".", // inline comment + "paths": { + "@/*": ["./src/*"] // path alias + } + } + }`, + "src/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("src"); + }); + + it("should parse tsconfig with multi-line comments", () => { + tempDir = createTempProject({ + "tsconfig.json": `{ + /* + * Multi-line comment + * explaining the config + */ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } + }`, + "src/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("src"); + }); + + it("should parse tsconfig with trailing commas", () => { + tempDir = createTempProject({ + "tsconfig.json": `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + }, + }, + }`, + "src/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("src"); + }); + }); + + describe("extends field resolution", () => { + it("should resolve paths from extended tsconfig", () => { + tempDir = createTempProject({ + "tsconfig.json": `{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + } + }`, + "tsconfig.base.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./src/*"], + "@lib/*": ["./lib/*"], + }, + }, + }), + "src/index.ts": "", + "lib/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("src"); + expect(aliases.get("@lib")).toBe("lib"); + }); + + it("should override extended paths with local paths", () => { + tempDir = createTempProject({ + "tsconfig.json": JSON.stringify({ + extends: "./tsconfig.base.json", + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./app/*"], // Override base + }, + }, + }), + "tsconfig.base.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./src/*"], + }, + }, + }), + "app/index.ts": "", + "src/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("app"); + }); + }); + + describe("webpack resolve.alias precedence", () => { + it("should prefer webpack alias over tsconfig paths", () => { + tempDir = createTempProject({ + "tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./src/*"], + }, + }, + }), + "src/index.ts": "", + "custom/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir, { + "@": path.join(tempDir, "custom"), + }); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("custom"); + }); + }); + + describe("Next.js fallback behavior", () => { + it("should fallback to @ -> src when no tsconfig paths and src exists", () => { + tempDir = createTempProject({ + "tsconfig.json": JSON.stringify({ + compilerOptions: { + strict: true, + // No paths defined + }, + }), + "src/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("src"); + }); + + it("should not add fallback if src directory does not exist", () => { + tempDir = createTempProject({ + "tsconfig.json": JSON.stringify({ + compilerOptions: { + strict: true, + }, + }), + "app/index.ts": "", // No src directory + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.has("@")).toBe(false); + }); + + it("should not add fallback if @ is already defined in tsconfig", () => { + tempDir = createTempProject({ + "tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./app/*"], + }, + }, + }), + "src/index.ts": "", + "app/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("app"); // Not "src" + }); + }); + + describe("edge cases", () => { + it("should handle missing tsconfig.json gracefully", () => { + tempDir = createTempProject({ + "src/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + // Should fall back to Next.js convention + expect(aliases.get("@")).toBe("src"); + }); + + it("should handle tsconfig with no compilerOptions", () => { + tempDir = createTempProject({ + "tsconfig.json": JSON.stringify({ + include: ["src/**/*"], + }), + "src/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + // Should fall back to Next.js convention + expect(aliases.get("@")).toBe("src"); + }); + + it("should handle paths with multiple targets (uses first)", () => { + tempDir = createTempProject({ + "tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./src/*", "./fallback/*"], + }, + }, + }), + "src/index.ts": "", + "fallback/index.ts": "", + }); + + const plugin = new CodePressWebpackPlugin({ dev: false, isServer: false }); + const compiler = createMockCompiler(tempDir); + + const getAliasMap = (plugin as any).getAliasMap.bind(plugin); + const aliases = getAliasMap(compiler); + + expect(aliases.get("@")).toBe("src"); // First target + }); + }); +});