diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8c08f64..ac7b872d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-node@v3 with: node-version: 18 - - run: npm i -g pnpm && pnpm i --ignore-scripts + - run: npm i -g pnpm && pnpm i name: Install dependencies - run: pnpm build --filter esbuild-plugin-react18-example name: build example app to run tests diff --git a/README.md b/README.md index 520acd62..c6dad050 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# esbuild-plugin-react18 [![test](https://github.com/mayank1513/esbuild-plugin-react18/actions/workflows/test.yml/badge.svg)](https://github.com/mayank1513/esbuild-plugin-react18/actions/workflows/test.yml) [![Version](https://img.shields.io/npm/v/esbuild-plugin-react18.svg?colorB=green)](https://www.npmjs.com/package/esbuild-plugin-react18) [![Downloads](https://img.jsdelivr.com/img.shields.io/npm/dt/esbuild-plugin-react18.svg)](https://www.npmjs.com/package/esbuild-plugin-react18) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/esbuild-plugin-react18) +# esbuild-plugin-react18 [![test](https://github.com/mayank1513/esbuild-plugin-react18/actions/workflows/test.yml/badge.svg)](https://github.com/mayank1513/esbuild-plugin-react18/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/mayank1513/esbuild-plugin-react18/graph/badge.svg)](https://codecov.io/gh/mayank1513/esbuild-plugin-react18) [![Version](https://img.shields.io/npm/v/esbuild-plugin-react18.svg?colorB=green)](https://www.npmjs.com/package/esbuild-plugin-react18) [![Downloads](https://img.jsdelivr.com/img.shields.io/npm/dt/esbuild-plugin-react18.svg)](https://www.npmjs.com/package/esbuild-plugin-react18) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/esbuild-plugin-react18) diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index f5955376..e334ba72 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -20,6 +20,7 @@ "@types/node": "^20.6.3", "@types/react": "^18.2.22", "@types/react-dom": "^18.2.7", + "esbuild-plugin-react18": "workspace:^", "eslint-config-custom": "workspace:*", "tsconfig": "workspace:*", "typescript": "^5.2.2" diff --git a/examples/vite/package.json b/examples/vite/package.json index e903d76e..76022c14 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -22,6 +22,7 @@ "@typescript-eslint/eslint-plugin": "^6.7.2", "@typescript-eslint/parser": "^6.7.2", "@vitejs/plugin-react-swc": "^3.3.2", + "esbuild-plugin-react18": "workspace:^", "eslint": "^8.50.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", diff --git a/packages/esbuild-plugin-react18-example/src/client/star-me/ignore-me.ts b/packages/esbuild-plugin-react18-example/src/client/star-me/ignore-me.ts new file mode 100644 index 00000000..2133a155 --- /dev/null +++ b/packages/esbuild-plugin-react18-example/src/client/star-me/ignore-me.ts @@ -0,0 +1,5 @@ +// ignore-me based on content + +/** ignore with content pattern */ + +export const IamIgnored = true; diff --git a/packages/esbuild-plugin-react18-example/src/server/constants.ts b/packages/esbuild-plugin-react18-example/src/server/constants.ts new file mode 100644 index 00000000..b231c4a2 --- /dev/null +++ b/packages/esbuild-plugin-react18-example/src/server/constants.ts @@ -0,0 +1,3 @@ +/** we will replace default colors to test sourceReplacePatterns */ +export const defaultBgColor = "#aaa"; +export const defaultColor = "#555"; diff --git a/packages/esbuild-plugin-react18-example/src/server/fork-me/fork-me.tsx b/packages/esbuild-plugin-react18-example/src/server/fork-me/fork-me.tsx index 33676886..0284ca3a 100644 --- a/packages/esbuild-plugin-react18-example/src/server/fork-me/fork-me.tsx +++ b/packages/esbuild-plugin-react18-example/src/server/fork-me/fork-me.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import cssClasses from "./fork-me.module.css"; +import { defaultBgColor, defaultColor } from "../constants"; interface ForkMeProps { gitHubUrl: string; @@ -29,8 +30,8 @@ export function ForkMe({ }: ForkMeProps) { const w = (Number.isNaN(Number(width)) ? width : `${width}px`) || "15em"; const h = (Number.isNaN(Number(height)) ? height : `${height}px`) || "35px"; - const bgC = bgColor || "#aaa"; - const tC = textColor || "#555"; + const bgC = bgColor || defaultBgColor; + const tC = textColor || defaultColor; const style = { "--w": w, "--h": h, diff --git a/packages/esbuild-plugin-react18/CHANGELOG.md b/packages/esbuild-plugin-react18/CHANGELOG.md index ac75acde..7c95845b 100644 --- a/packages/esbuild-plugin-react18/CHANGELOG.md +++ b/packages/esbuild-plugin-react18/CHANGELOG.md @@ -1,5 +1,13 @@ # esbuild-plugin-react18 +## 0.0.3 + +### Patch Changes + +- e5d3a58: remove data-testid as last item in sourceReplacePatterns. This will reduce the onLoad conflicts significantly as this pattern matches all the js, ts, jsx and tsx files. +- Fix buildReplacePatterns to work on all esbuild setups +- e186209: Fix ignorePatterns with contentPatterns + ## 0.0.2 ### Patch Changes diff --git a/packages/esbuild-plugin-react18/__tests__/buildReplacePatterns.test.ts b/packages/esbuild-plugin-react18/__tests__/buildReplacePatterns.test.ts new file mode 100644 index 00000000..29f36305 --- /dev/null +++ b/packages/esbuild-plugin-react18/__tests__/buildReplacePatterns.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, test, beforeAll } from "vitest"; +import esbuild from "esbuild"; +import react18Plugin from "../src"; +import glob from "tiny-glob"; + +/** + * buildReplacePatterns could be very helpful in removing unnecessary comments introduced while bundling from other libraries + */ +describe.concurrent("Test plugin with ignorePatterns -- without content pattern", async () => { + const outDir = "build-replace-patterns"; + const exampleBuildDir = path.resolve(process.cwd(), "dist", outDir); + try { + fs.unlinkSync(path.resolve(exampleBuildDir)); + } catch {} + beforeAll(async () => { + await esbuild.build({ + format: "cjs", + target: "es2019", + sourcemap: false, + bundle: true, + minify: true, + plugins: [ + react18Plugin({ + buildReplacePatterns: [ + { + pathPattern: /constants/, + replaceParams: [ + { pattern: /aaa/, substitute: "3c3c3c" }, + { pattern: /#555/, substitute: "#ccc" }, + ], + }, + ], + }), + ], + entryPoints: await glob("../esbuild-plugin-react18-example/src/**/*.*"), + publicPath: "https://my.domain/static/", + external: ["react", "react-dom"], + outdir: "./dist/" + outDir, + }); + }); + + test(`"use client"; directive should be present in client components`, ({ expect }) => { + const text = fs.readFileSync(path.resolve(exampleBuildDir, "client", "index.js"), "utf-8"); + expect(/^"use client";\n/m.test(text)).toBe(true); + }); + test(`"use client"; directive should not be present in server components`, ({ expect }) => { + const text = fs.readFileSync(path.resolve(exampleBuildDir, "server", "index.js"), "utf-8"); + expect(/^"use client";\n/m.test(text)).toBe(false); + }); + test(`defaultBgColor should be "#3c3c3c" and defaultColor should be "#ccc"`, ({ expect }) => { + const text = fs.readFileSync(path.resolve(exampleBuildDir, "server", "constants.js"), "utf-8"); + expect(text.includes("3c3c3c")).toBe(true); + }); +}); diff --git a/packages/esbuild-plugin-react18/__tests__/defaultOptions.test.ts b/packages/esbuild-plugin-react18/__tests__/defaultOptions.test.ts index 888587b2..e1620a3c 100644 --- a/packages/esbuild-plugin-react18/__tests__/defaultOptions.test.ts +++ b/packages/esbuild-plugin-react18/__tests__/defaultOptions.test.ts @@ -1,15 +1,15 @@ import fs from "node:fs"; import path from "node:path"; -import { describe, test } from "vitest"; +import { describe, test, beforeAll } from "vitest"; -describe.concurrent("Test plugin with default options", () => { +/** testing tsup example - make sure it is build before running this test suit */ +describe.concurrent("Test plugin with default options in example build with tsup", () => { const exampleBuildDir = path.resolve( process.cwd(), "..", "esbuild-plugin-react18-example", "dist", ); - console.log({ exampleBuildDir }); test(`"use client"; directive should be present in client components`, ({ expect }) => { const text = fs.readFileSync(path.resolve(exampleBuildDir, "client", "index.js"), "utf-8"); expect(/^"use client";\n/m.test(text)).toBe(true); @@ -18,4 +18,11 @@ describe.concurrent("Test plugin with default options", () => { const text = fs.readFileSync(path.resolve(exampleBuildDir, "server", "index.js"), "utf-8"); expect(/^"use client";\n/m.test(text)).toBe(false); }); + test(`should not contain data-testid`, ({ expect }) => { + const text = fs.readFileSync( + path.resolve(exampleBuildDir, "client", "star-me", "star-me.js"), + "utf-8", + ); + expect(/data-testid/.test(text)).toBe(false); + }); }); diff --git a/packages/esbuild-plugin-react18/__tests__/ignorePatterns.test.ts b/packages/esbuild-plugin-react18/__tests__/ignorePatterns.test.ts new file mode 100644 index 00000000..9f9ae11e --- /dev/null +++ b/packages/esbuild-plugin-react18/__tests__/ignorePatterns.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, test, beforeAll } from "vitest"; +import esbuild from "esbuild"; +import react18Plugin from "../src"; +import glob from "tiny-glob"; + +describe.concurrent("Test plugin with ignorePatterns -- without content pattern", async () => { + const outDir = "ignore-patterns-0"; + const exampleBuildDir = path.resolve(process.cwd(), "dist", outDir); + try { + fs.unlinkSync(path.resolve(exampleBuildDir)); + } catch {} + beforeAll(async () => { + await esbuild.build({ + format: "cjs", + target: "es2019", + sourcemap: false, + bundle: true, + minify: true, + plugins: [react18Plugin({ ignorePatterns: [{ pathPattern: /star-me/ }] })], + entryPoints: await glob("../esbuild-plugin-react18-example/src/**/*.*"), + publicPath: "https://my.domain/static/", + external: ["react", "react-dom"], + outdir: "./dist/" + outDir, + }); + }); + + test(`"use client"; directive should be present in client components`, ({ expect }) => { + const text = fs.readFileSync(path.resolve(exampleBuildDir, "client", "index.js"), "utf-8"); + expect(/^"use client";\n/m.test(text)).toBe(true); + }); + test(`"use client"; directive should not be present in server components`, ({ expect }) => { + const text = fs.readFileSync(path.resolve(exampleBuildDir, "server", "index.js"), "utf-8"); + expect(/^"use client";\n/m.test(text)).toBe(false); + }); + test(`star-me.tsx file should not exist`, ({ expect }) => { + expect(fs.existsSync(path.resolve(exampleBuildDir, "client", "star-me"))).toBe(false); + }); +}); + +/** + * When content pattern is provided only the ignorePattern files having content matching the content pattern will be removed + */ +describe.concurrent("Test plugin with ignorePatterns with content pattern", async () => { + const outDir = "ignore-patterns-1"; + beforeAll(async () => { + await esbuild.build({ + format: "cjs", + target: "es2019", + sourcemap: false, + bundle: true, + minify: true, + plugins: [ + react18Plugin({ + ignorePatterns: [{ pathPattern: /star-me/, contentPatterns: [/ignore-me/] }], + }), + ], + entryPoints: await glob("../esbuild-plugin-react18-example/src/**/*.*"), + publicPath: "https://my.domain/static/", + external: ["react", "react-dom"], + outdir: "./dist/" + outDir, + }); + }); + + const exampleBuildDir = path.resolve(process.cwd(), "dist", outDir); + test(`star-me.tsx file should exist`, ({ expect }) => { + expect(fs.existsSync(path.resolve(exampleBuildDir, "client", "star-me", "star-me.js"))).toBe( + true, + ); + }); + test(`ignore-me.ts file should not exist as it contains content "ignore-me" (Note: path pattern is still star-me)`, ({ + expect, + }) => { + expect(fs.existsSync(path.resolve(exampleBuildDir, "client", "star-me", "ignore-me.js"))).toBe( + false, + ); + }); +}); diff --git a/packages/esbuild-plugin-react18/__tests__/sourceReplacePatterns.test.ts b/packages/esbuild-plugin-react18/__tests__/sourceReplacePatterns.test.ts new file mode 100644 index 00000000..43c2e9b4 --- /dev/null +++ b/packages/esbuild-plugin-react18/__tests__/sourceReplacePatterns.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, test, beforeAll } from "vitest"; +import esbuild from "esbuild"; +import react18Plugin from "../src"; +import glob from "tiny-glob"; + +describe.concurrent("Test plugin with ignorePatterns -- without content pattern", async () => { + const outDir = "source-replace-patterns"; + const exampleBuildDir = path.resolve(process.cwd(), "dist", outDir); + try { + fs.unlinkSync(path.resolve(exampleBuildDir)); + } catch {} + beforeAll(async () => { + await esbuild.build({ + format: "cjs", + target: "es2019", + sourcemap: false, + bundle: true, + minify: true, + plugins: [ + react18Plugin({ + sourceReplacePatterns: [ + { + pathPattern: /constants/, + replaceParams: [ + { pattern: /aaa/, substitute: "3c3c3c" }, + { pattern: /#555/, substitute: "#ccc" }, + ], + }, + ], + }), + ], + entryPoints: await glob("../esbuild-plugin-react18-example/src/**/*.*"), + publicPath: "https://my.domain/static/", + external: ["react", "react-dom"], + outdir: "./dist/" + outDir, + }); + }); + + test(`"use client"; directive should be present in client components`, ({ expect }) => { + const text = fs.readFileSync(path.resolve(exampleBuildDir, "client", "index.js"), "utf-8"); + expect(/^"use client";\n/m.test(text)).toBe(true); + }); + test(`"use client"; directive should not be present in server components`, ({ expect }) => { + const text = fs.readFileSync(path.resolve(exampleBuildDir, "server", "index.js"), "utf-8"); + expect(/^"use client";\n/m.test(text)).toBe(false); + }); + test(`defaultBgColor should be "#3c3c3c" and defaultColor should be "#ccc"`, ({ expect }) => { + const text = fs.readFileSync(path.resolve(exampleBuildDir, "server", "constants.js"), "utf-8"); + expect(text.includes("3c3c3c")).toBe(true); + }); +}); diff --git a/packages/esbuild-plugin-react18/package.json b/packages/esbuild-plugin-react18/package.json index f6663a75..98aaf136 100644 --- a/packages/esbuild-plugin-react18/package.json +++ b/packages/esbuild-plugin-react18/package.json @@ -2,7 +2,7 @@ "name": "esbuild-plugin-react18", "author": "Mayank Kumar Chaudhari ", "private": false, - "version": "0.0.2", + "version": "0.0.3", "description": "Unleash the Power of React Server Components! ESBuild plugin to build RSC (React18 Server Components) compatible libraries.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -16,6 +16,7 @@ "@vitest/coverage-v8": "^0.34.5", "esbuild": "^0.18.17", "octokit": "^3.1.0", + "tiny-glob": "^0.2.9", "typescript": "^5.1.6", "vitest": "^0.34.1" }, diff --git a/packages/esbuild-plugin-react18/src/index.ts b/packages/esbuild-plugin-react18/src/index.ts index 0a48c550..3966bd57 100644 --- a/packages/esbuild-plugin-react18/src/index.ts +++ b/packages/esbuild-plugin-react18/src/index.ts @@ -3,10 +3,10 @@ import fs from "node:fs"; import path from "node:path"; type React18PluginOptions = { - /** do not ignore tese files */ + /** to not ignore tese files */ keepTests?: boolean; - /** do not remove `data-testid` attributes. If `keepTests` is true, + /** to not remove `data-testid` attributes. If `keepTests` is true, * `data-testid` attributes will not be removed irrespective of * `keepTestIds` value. * This attribute is useful when setting `sourceReplacePatterns` @@ -48,51 +48,53 @@ type React18PluginOptions = { }; /** This plugin prevents building test files by esbuild. DTS may still geenrate type files for the tests with only { } as file content*/ -const react18Plugin: (options?: React18PluginOptions) => Plugin = options => ({ +const react18Plugin: (options: React18PluginOptions) => Plugin = options => ({ name: "esbuild-plugin-react18-" + uuid(), setup(build) { + if (!options) options = {}; const ignoreNamespace = "mayank1513-ignore-" + uuid(); + const keepNamespace = "mayank1513-keep-" + uuid(); const testPathRegExp = /.*\.(test|spec|check)\.(j|t)s(x)?$/i; - if (!options?.keepTests) { + + const write = build.initialOptions.write; + build.initialOptions.write = false; + + if (!options.keepTests) { build.onResolve({ filter: testPathRegExp }, args => ({ path: args.path, namespace: ignoreNamespace, })); - if (!options?.keepTestIds) { + if (!options.keepTestIds) { /** remove data-testid */ - build.onLoad({ filter: /.*\.(j|t)s(x)?$/, namespace: "file" }, args => { - const text = fs.readFileSync(args.path, "utf8"); - const loader = args.path.slice(args.path.lastIndexOf(".") + 1); - return { - contents: text.replace(/\s*data-testid="[^"]*"/gm, " "), - loader, - } as OnLoadResult; + if (!options.sourceReplacePatterns) options.sourceReplacePatterns = []; + options.sourceReplacePatterns.push({ + pathPattern: /.*\.(j|t)s(x)?$/, + replaceParams: [{ pattern: /\s*data-testid="[^"]*"/gm, substitute: " " }], }); } } - options?.ignorePatterns?.forEach(ignorePattern => { + options.ignorePatterns?.forEach(ignorePattern => { build.onResolve({ filter: ignorePattern.pathPattern }, args => { /** remove content to avoid building/transpiling test files unnecessarily*/ - if (!ignorePattern.contentPatterns?.length) - return { - path: args.path, - namespace: ignoreNamespace, - }; - const text = fs.readFileSync(path.resolve(args.resolveDir, args.path), "utf8"); - for (const contentPattern of ignorePattern.contentPatterns) { - if (contentPattern.test(text)) { - return { - path: args.path, - namespace: ignoreNamespace, - }; + const fullPath = path.resolve(args.resolveDir, args.path); + if (!ignorePattern.contentPatterns?.length || !fs.existsSync(fullPath)) + return { path: args.path, namespace: ignoreNamespace }; + + if (!fs.lstatSync(fullPath).isDirectory()) { + const text = fs.readFileSync(fullPath, "utf8"); + for (const contentPattern of ignorePattern.contentPatterns) { + if (contentPattern.test(text)) { + return { path: args.path, namespace: ignoreNamespace }; + } } } - return { path: args.path }; + return { path: fullPath, namespace: keepNamespace }; }); }); - options?.sourceReplacePatterns?.forEach(sourceReplacePattern => { + options.sourceReplacePatterns?.forEach(sourceReplacePattern => { + if (sourceReplacePattern.replaceParams.length === 0) return; /** Add namespace file to avoid conflict with ignored files */ build.onLoad({ filter: sourceReplacePattern.pathPattern, namespace: "file" }, args => { let contents = fs.readFileSync(args.path, "utf8"); @@ -109,10 +111,18 @@ const react18Plugin: (options?: React18PluginOptions) => Plugin = options => ({ build.onLoad({ filter: /.*/, namespace: ignoreNamespace }, args => { /** remove content to avoid building/transpiling test files unnecessarily*/ - console.log("onLoad", args); return { contents: "" }; }); + build.onLoad({ filter: /.*/, namespace: keepNamespace }, args => { + if (fs.existsSync(args.path) && fs.lstatSync(args.path).isDirectory()) + return { contents: "" }; + else { + const loader = args.path.slice(args.path.lastIndexOf(".") + 1); + return { contents: fs.readFileSync(args.path, "utf-8"), loader } as OnLoadResult; + } + }); + const useClientRegExp = /['"]use client['"]\s?;/i; build.onEnd(result => { @@ -121,14 +131,13 @@ const react18Plugin: (options?: React18PluginOptions) => Plugin = options => ({ .forEach(f => { const txt = f.text; if (txt.match(useClientRegExp)) { - Object.defineProperty(f, "text", { - value: '"use client";\n' + txt.replace(useClientRegExp, ""), - }); + const text = '"use client";\n' + txt.replace(useClientRegExp, ""); + f.contents = new TextEncoder().encode(text); } }); /** handle buildReplacePatterns */ - options?.buildReplacePatterns?.forEach(buildReplacePattern => { + options.buildReplacePatterns?.forEach(buildReplacePattern => { result.outputFiles ?.filter(f => buildReplacePattern.pathPattern.test(f.path)) .forEach(f => { @@ -136,14 +145,24 @@ const react18Plugin: (options?: React18PluginOptions) => Plugin = options => ({ buildReplacePattern.replaceParams.forEach(({ pattern, substitute }) => { text = text.replace(pattern, substitute); }); - Object.defineProperty(f, "text", { value: text }); + f.contents = new TextEncoder().encode(text); }); }); /** Do not generate {empty} test files if keepTests is not set to true */ - if (!options?.keepTests) { + if (!options.keepTests) { result.outputFiles = result.outputFiles?.filter(f => !testPathRegExp.test(f.path)); } + + /** remove empty files */ + result.outputFiles = result.outputFiles?.filter(f => f.text !== ""); + /** assume true if undefined */ + if (write === undefined || write) { + result.outputFiles?.forEach(file => { + fs.mkdirSync(path.dirname(file.path), { recursive: true }); + fs.writeFileSync(file.path, file.contents); + }); + } }); }, }); diff --git a/packages/esbuild-plugin-react18/touchup.js b/packages/esbuild-plugin-react18/touchup.js index 25a9c3e2..7b315ae8 100644 --- a/packages/esbuild-plugin-react18/touchup.js +++ b/packages/esbuild-plugin-react18/touchup.js @@ -10,16 +10,7 @@ delete packageJson.scripts; packageJson.main = "index.js"; packageJson.types = "index.d.ts"; -fs.writeFileSync( - path.resolve(__dirname, "dist", "package.json"), - JSON.stringify(packageJson, null, 2), -); - -fs.copyFileSync( - path.resolve(__dirname, "..", "..", "README.md"), - path.resolve(__dirname, "dist", "README.md"), -); - +console.log(process.env.TOKEN, process.env.OWNER, process.env.REPO); if (process.env.TOKEN) { const { Octokit } = require("octokit"); // Octokit.js @@ -55,3 +46,13 @@ if (process.env.TOKEN) { console.log("octokit error", e); } } + +fs.writeFileSync( + path.resolve(__dirname, "dist", "package.json"), + JSON.stringify(packageJson, null, 2), +); + +fs.copyFileSync( + path.resolve(__dirname, "..", "..", "README.md"), + path.resolve(__dirname, "dist", "README.md"), +);