diff --git a/.changeset/upgrade.md b/.changeset/upgrade.md new file mode 100644 index 00000000..1804134c --- /dev/null +++ b/.changeset/upgrade.md @@ -0,0 +1,17 @@ +--- +esbuild-raw-plugin: minor +--- + +### ✨ Enhancements + +- Replaced `textExtensions` with `customLoaders` for fine-grained extension-to-loader mapping. +- Introduced `name` option for overriding the plugin name (useful for debugging or deduplication). +- Added support for multiple query-based loaders: `?text`, `?base64`, `?dataurl`, `?file`, `?binary`. +- Improved fallback logic for resolving files: now tries extensions or `index.[ext]` for folders. +- Regex-based `onLoad` filtering boosts performance (leveraging Go-native ESBuild internals). + +### πŸ›  Internal Refactors + +- Code refactored for better readability and maintainability. +- Error messages are now clearer and more actionable. +- Switched to consistent plugin naming (`"esbuild-raw-plugin"` instead of randomized suffix). diff --git a/.gitignore b/.gitignore index 544fb870..3503063f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ node_modules .turbo *.log .next -dist +dist* dist-ssr *.local .env diff --git a/README.md b/README.md index 8122fad5..bb894675 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,45 @@ # Esbuild Raw Plugin -[![test](https://github.com/react18-tools/esbuild-raw-plugin/actions/workflows/test.yml/badge.svg)](https://github.com/react18-tools/esbuild-raw-plugin/actions/workflows/test.yml) [![Maintainability](https://api.codeclimate.com/v1/badges/aa896ec14c570f3bb274/maintainability)](https://codeclimate.com/github/react18-tools/esbuild-raw-plugin/maintainability) [![codecov](https://codecov.io/gh/react18-tools/esbuild-raw-plugin/graph/badge.svg)](https://codecov.io/gh/react18-tools/esbuild-raw-plugin) [![Version](https://img.shields.io/npm/v/esbuild-raw-plugin.svg?colorB=green)](https://www.npmjs.com/package/esbuild-raw-plugin) [![Downloads](https://img.jsdelivr.com/img.shields.io/npm/d18m/esbuild-raw-plugin.svg)](https://www.npmjs.com/package/esbuild-raw-plugin) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/esbuild-raw-plugin) [![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/from-referrer/) +[![test](https://github.com/react18-tools/esbuild-raw-plugin/actions/workflows/test.yml/badge.svg)](https://github.com/react18-tools/esbuild-raw-plugin/actions/workflows/test.yml) +[![Maintainability](https://api.codeclimate.com/v1/badges/aa896ec14c570f3bb274/maintainability)](https://codeclimate.com/github/react18-tools/esbuild-raw-plugin/maintainability) +[![codecov](https://codecov.io/gh/react18-tools/esbuild-raw-plugin/graph/badge.svg)](https://codecov.io/gh/react18-tools/esbuild-raw-plugin) +[![Version](https://img.shields.io/npm/v/esbuild-raw-plugin.svg?colorB=green)](https://www.npmjs.com/package/esbuild-raw-plugin) +[![Downloads](https://img.jsdelivr.com/img.shields.io/npm/d18m/esbuild-raw-plugin.svg)](https://www.npmjs.com/package/esbuild-raw-plugin) +![npm bundle size](https://img.shields.io/bundlephobia/minzip/esbuild-raw-plugin) +[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/from-referrer/) -**An ESBuild/TSUP plugin to import files as raw text.** -Ideal for scenarios like importing code files for documentation, interactive tools like `react-live`, or other text-based use cases. +**Lightweight ESBuild/TSUP plugin to import files as raw content β€” zero config required.** -> Star [this repository](https://github.com/react18-tools/esbuild-raw-plugin) and share it with your friends. +> Import `.ts`, `.js`, `.css`, `.scss`, `.md`, `.html`, `.docx`, and more β€” perfect for documentation, live editors (`react-live`), markdown tooling, or template-driven workflows. +> Power users: Load `.docx` templates directly for [mdast2docx](https://github.com/md2docx/mdast2docx). + +> Star [this repository](https://github.com/react18-tools/esbuild-raw-plugin) and share it with your dev circle. --- -## Features +## πŸš€ Features -- Import any file (e.g., `.js`, `.ts`, `.css`, etc.) as raw text. -- Works seamlessly with **ESBuild** and **TSUP**. -- Perfect for documentation generators, live code editors, and similar tools. +- πŸ”§ Supports `?raw`, `?text`, `?base64`, `?dataurl`, `?binary`, and `?file` query suffixes +- 🧠 Smart fallback to extensions like `.ts`, `.tsx`, `index.[ext]`, etc. +- πŸ” Custom loader mapping (e.g., `module.scss` β†’ `text`, `png` β†’ `dataurl`) +- ⚑ Ultra-fast using regex-based native `onLoad` filter (Go-native perf) +- πŸͺΆ Works seamlessly with both [Tsup](https://tsup.egoist.dev/) and [ESBuild](https://esbuild.github.io/) --- -## Installation - -Using npm: +## πŸ“¦ Installation ```bash npm install esbuild-raw-plugin --save-dev ``` -Using yarn: +_or_ ```bash yarn add esbuild-raw-plugin --dev ``` -Using pnpm: +_or_ ```bash pnpm add esbuild-raw-plugin --save-dev @@ -39,13 +47,11 @@ pnpm add esbuild-raw-plugin --save-dev --- -## Usage +## πŸ›  Usage -### ESBuild Configuration +### ➀ With ESBuild -Add the plugin to your ESBuild configuration: - -```js +```ts import { build } from "esbuild"; import { raw } from "esbuild-raw-plugin"; @@ -57,11 +63,9 @@ build({ }); ``` -### TSUP Configuration +### ➀ With TSUP -Add the plugin to your TSUP configuration: - -```js +```ts import { defineConfig } from "tsup"; import { raw } from "esbuild-raw-plugin"; @@ -74,135 +78,141 @@ export default defineConfig({ --- -## IDE Setup for IntelliSense and Type Checking +## 🧠 TypeScript Support -Add the following to your declaration file. If you do not have one, create a `declarations.d.ts` file and add the following: +Add this to your `declarations.d.ts` file: -```typescript +```ts declare module "*?raw" { - const value: string; - export default value; + const content: string; + export default content; } ``` -## Importing Files as Raw Text +> For other suffixes (`?base64`, `?binary`, etc.), add similar declarations if needed. + +--- -With the plugin enabled, you can import files as raw text directly: +## πŸ“₯ Importing Raw Files -```js -import myCode from "./example.js?raw"; +```ts +import content from "./example.js?raw"; -console.log(myCode); -// Outputs the content of 'example.js' as a string. +console.log(content); // Entire file content as string or Buffer ``` -### Good News: +### βœ… Simplified Imports -With the latest update, you no longer need to specify the file extension explicitly. +You don’t need to specify full filenames or extensions: -```js -import myCode from "./example?raw"; +```ts +import code from "./utils?raw"; // Resolves to utils/index.ts, utils.js, etc. ``` -This works seamlessly! Additionally, if you're exporting from files like `index.tsx`, `index.jsx`, etc., you can simplify imports. For example, if your file path is `my-lib/index.ts`, you can import the raw content like this: +Great for: -```js -import myCode from "./my-lib?raw"; -``` +- Library or folder-level imports +- Auto-resolving `.ts`, `.tsx`, `.css`, `.scss`, etc. -### Extension Options (Optional) +--- + +## βš™οΈ Plugin Options ```ts export interface RawPluginOptions { - /** - * Extensions to check in order if the file does not exist. - * If it's a directory, the plugin will look for `dir/index.[ext]`. - * @defaultValue ["tsx", "ts", "jsx", "js", "mjs", "mts", "module.css", "module.scss", "css", "scss"] - * - * You can provide your own extensions to optimize build performance or extend the list based on your use case. - */ ext?: string[]; - - /** - * Custom loader for file processing. - * @defaultValue "text" - */ loader?: "text" | "base64" | "dataurl" | "file" | "binary" | "default"; - - /** - * Extensions to be treated as text files. - */ - textExtensions?: string[]; + customLoaders?: Record; + name?: string; } ``` -### Supported File Types +
+πŸ”§ Option Details -You can use `?raw` with any file type, including: +- `ext`: Extensions to resolve if the file or folder is missing. Defaults to common types like `ts`, `tsx`, `module.css`, etc. +- `loader`: Default loader if no `?query` is specified. Usually `"text"`. +- `customLoaders`: Per-extension loader mapping. Example: -- `.js`, `.ts`, `.jsx`, `.tsx` -- `.css`, `.scss` -- `.html` -- `.md` -- and more! + ```ts + { + "module.scss": "text", + "png": "dataurl", + "docx": "file" + } + ``` ---- +- `name`: Optional plugin name override for debugging or deduplication. -## Example Use Case +
-### Live Code Preview with `react-live` +--- -```jsx -import React from "react"; -import { LiveProvider, LiveEditor, LiveError, LivePreview } from "react-live"; -import exampleCode from "./example.js?raw"; +## πŸ§ͺ Supported Query Loaders -const App = () => ( - - - - - -); +Import with query-based syntax: -export default App; +```ts +import doc from "./readme.md?text"; +import logo from "./logo.png?base64"; +import wasm from "./core.wasm?binary"; ``` ---- - -## Why Use `esbuild-raw-plugin`? - -- Simplifies importing files as raw text for documentation and live previews. -- Seamlessly integrates with modern build tools like ESBuild and TSUP. -- Lightweight and easy to configure. +| Query Suffix | Description | +| ------------ | -------------------------------------------------- | +| `?raw` | Uses the default loader (options.loader ?? "text") | +| `?text` | Loads file as UTF-8 text | +| `?base64` | Returns base64 string | +| `?dataurl` | Returns full data URL | +| `?file` | Emits file to output dir | +| `?binary` | Returns raw `Buffer` | --- -## Keywords +## 🧬 Use Case: Live Code Preview -`esbuild`, `esbuild-plugin`, `tsup-plugin`, `raw-text-import`, `import-as-text`, `file-loader`, `react-live`, `documentation-tools`, `frontend-tooling` +```tsx +import { LiveProvider, LiveEditor, LiveError, LivePreview } from "react-live"; +import exampleCode from "./example.js?raw"; + +export default function LiveDemo() { + return ( + + + + + + ); +} +``` --- -## Contributing +## πŸ” Why Choose `esbuild-raw-plugin`? -Contributions are welcome! -Feel free to open issues or pull requests to improve the plugin. +- βœ… Works out of the box β€” no config needed +- πŸ“ Handles smart file resolution +- πŸ’¬ Excellent developer experience +- 🧩 Supports both query-based and extension-based mappings +- πŸ§ͺ Stable, fast, and production-tested --- -Let me know if you'd like further tweaks! πŸš€ +## πŸ›  Contributing + +PRs and ideas welcome! +Open an issue or submit a pull request to help improve the plugin. ![Alt](https://repobeats.axiom.co/api/embed/1ae166ef108b33b36ceaa60be208a5dafce25c5c.svg "Repobeats analytics image") --- -## License +## 🧾 License -This library is licensed under the MPL-2.0 open-source license. +Licensed under the **MPL-2.0** open-source license. -> Please enroll in [our courses](https://mayank-chaudhari.vercel.app/courses) or [sponsor](https://github.com/sponsors/mayank1513) our work. +> Please consider [sponsoring](https://github.com/sponsors/mayank1513) or [joining a course](https://mayank-chaudhari.vercel.app/courses) to support this work. -
+---

with πŸ’– by Mayank Kumar Chaudhari

diff --git a/lib/__tests__/declarations.d.ts b/lib/__tests__/declarations.d.ts index 7adcba24..9800d7e6 100644 --- a/lib/__tests__/declarations.d.ts +++ b/lib/__tests__/declarations.d.ts @@ -1,2 +1,7 @@ +declare module "*?file"; +declare module "*?base64"; +declare module "*?binary"; +declare module "*?buffer"; +declare module "*?text"; declare module "*?raw"; declare module "*.md"; diff --git a/lib/__tests__/index.test.ts b/lib/__tests__/index.test.ts index 640fd757..805e85ea 100644 --- a/lib/__tests__/index.test.ts +++ b/lib/__tests__/index.test.ts @@ -27,10 +27,24 @@ describe("Raw plugin", () => { test("test raw import with auto ext", async ({ expect }) => { await esbuild.build({ ...buildOptions, entryPoints: [path.resolve(__dirname, "test1.ts")] }); - const fileContent = fs.readFileSync(path.resolve(__dirname, "../src/index.ts"), "utf-8"); + const fileContent = fs.readFileSync(path.resolve(__dirname, "../src/index.ts")); // @ts-ignore const generatedCodeContent = (await import("./dist/test1.js")).getText(); - expect(fileContent).toBe(generatedCodeContent); + // @ts-ignore + const generatedCodeContent2 = (await import("./dist/test1.js")).getText2(); + expect(generatedCodeContent).toBe(fileContent.toString("base64")); + expect(generatedCodeContent2.toString("base64")).toBe(fileContent.toString("base64")); + }); + + test("test buffer", async ({ expect }) => { + await esbuild.build({ ...buildOptions, entryPoints: [path.resolve(__dirname, "test4.ts")] }); + const fileContent = fs.readFileSync(path.resolve(__dirname, "../src/index.ts")); + // @ts-ignore + const generatedCodeContent = (await import("./dist/test4.js")).getBuffer(); + // @ts-ignore + expect(Buffer.from(generatedCodeContent).toString("base64")).toBe( + fileContent.toString("base64"), + ); }); test("throws error if no file is found", async ({ expect }) => { @@ -46,7 +60,7 @@ describe("Raw plugin", () => { test("textExtensions", async ({ expect }) => { await esbuild.build({ ...buildOptions, - plugins: [raw({ textExtensions: [".md"] })], + plugins: [raw({ customLoaders: { md: "text" } })], entryPoints: [path.resolve(__dirname, "test3.ts")], }); @@ -56,15 +70,45 @@ describe("Raw plugin", () => { expect(fileContent).toBe(generatedCodeContent); }); + test("uses custom loader if provided", async ({ expect }) => { + await esbuild.build({ + ...buildOptions, + entryPoints: [path.resolve(__dirname, "test-loader.ts")], + plugins: [raw({ loader: "base64" })], + outdir: "__tests__/dist2", + }); + const fileContent = fs.readFileSync(path.resolve(__dirname, "../src/index.ts")); + // @ts-ignore + const generatedCodeContent = (await import("./dist2/test-loader.js")).getText(); + expect(generatedCodeContent).toBe(fileContent.toString("base64")); + }); + + test("uses customLoaders mapping for extension", async ({ expect }) => { + await esbuild.build({ + ...buildOptions, + plugins: [raw({ customLoaders: { md: "text" } })], + entryPoints: [path.resolve(__dirname, "test3.ts")], + outdir: "__tests__/dist3", + }); + const fileContent = fs.readFileSync(path.resolve(__dirname, "test.md"), "utf-8"); + // @ts-ignore + const generatedCodeContent = (await import("./dist3/test3.js")).getText(); + expect(generatedCodeContent).toBe(fileContent); + }); + + test("uses custom plugin name if provided", ({ expect }) => { + const plugin = raw({ name: "custom-plugin-name" }); + expect(plugin.name).toBe("custom-plugin-name"); + }); test("custom loader", async ({ expect }) => { await esbuild.build({ ...buildOptions, entryPoints: [path.resolve(__dirname, "test-loader.ts")], plugins: [raw({ loader: "base64" })], }); - const fileContent = fs.readFileSync(path.resolve(__dirname, "../src/index.ts"), "utf-8"); + const fileContent = fs.readFileSync(path.resolve(__dirname, "../src/index.ts")); // @ts-ignore const generatedCodeContent = (await import("./dist/test-loader.js")).getText(); - expect(fileContent).toBe(atob(generatedCodeContent)); + expect(generatedCodeContent).toBe(fileContent.toString("base64")); }); }); diff --git a/lib/__tests__/test1.ts b/lib/__tests__/test1.ts index 4f2e2232..4555ee28 100644 --- a/lib/__tests__/test1.ts +++ b/lib/__tests__/test1.ts @@ -1,5 +1,7 @@ // test auto complete -import text from "../src?raw"; +import text from "../src?base64"; +import text2 from "../src?binary"; export const getText = () => text; +export const getText2 = () => text2; diff --git a/lib/__tests__/test2.ts b/lib/__tests__/test2.ts index ad640664..aa3e32a3 100644 --- a/lib/__tests__/test2.ts +++ b/lib/__tests__/test2.ts @@ -1,5 +1,5 @@ // test auto error -import text from "../src/my-file?raw"; +import text from "../src/my-file?buffer"; export const getText = () => text; diff --git a/lib/__tests__/test4.ts b/lib/__tests__/test4.ts new file mode 100644 index 00000000..44a3afde --- /dev/null +++ b/lib/__tests__/test4.ts @@ -0,0 +1,5 @@ +// test auto complete + +import buffer from "../src?binary"; + +export const getBuffer = () => buffer; diff --git a/lib/src/index.ts b/lib/src/index.ts index 770fe871..592f21b3 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -2,6 +2,19 @@ import type { Plugin, PluginBuild } from "esbuild"; import fs from "node:fs"; import path from "node:path"; +const DEFAULT_EXT_ORDER_LIST = [ + "ts", + "tsx", + "js", + "jsx", + "mjs", + "mts", + "module.css", + "module.scss", + "css", + "scss", +]; + export interface RawPluginOptions { /** * File extensions to check in order of priority if the specified file is missing. @@ -12,14 +25,21 @@ export interface RawPluginOptions { /** * Custom loader for file processing. + * Overridden by import query suffix (?text, ?base64, etc). * @defaultValue "text" */ loader?: "text" | "base64" | "dataurl" | "file" | "binary" | "default"; /** - * Extensions to be treated as text files. + * Map file extensions (without dot) to custom loaders. + * Example: { md: "text", png: "dataurl" } */ - textExtensions?: string[]; + customLoaders?: Record; + + /** + * Plugin name override (for debugging, deduplication, etc.) + */ + name?: string; } /** @@ -29,70 +49,88 @@ export interface RawPluginOptions { * treating them as raw text content. It supports resolving file * extensions in order of priority and handling custom loaders. */ -export const raw: (options?: RawPluginOptions) => Plugin = options => ({ - name: `raw-${Math.random().toString(36).slice(2, 10)}`, +export const raw = (options?: RawPluginOptions): Plugin => ({ + name: options?.name || "esbuild-raw-plugin", setup(build: PluginBuild) { - const ext = options?.ext ?? [ - "ts", - "tsx", - "js", - "jsx", - "mjs", - "mts", - "module.css", - "module.scss", - "css", - "scss", - ]; - - build.onResolve({ filter: /\?raw$/ }, args => ({ - path: args.path, - pluginData: path.resolve(args.resolveDir, args.path).replace(/\?raw$/, ""), - namespace: "raw", - })); - - build.onLoad({ filter: /\?raw$/, namespace: "raw" }, args => { - let filePath = args.pluginData; - if (options?.loader && options.loader !== "text") { - return { contents: fs.readFileSync(filePath, "utf8"), loader: options.loader }; - } + const ext = options?.ext?.map(e => e.replace(/^\./, "")) ?? DEFAULT_EXT_ORDER_LIST; + + build.onResolve({ filter: /\?(raw|text|buffer|binary|base64|dataurl|file)$/ }, args => { + const i = args.path.lastIndexOf("?"); + const filepath = i !== -1 ? args.path.slice(0, i) : args.path; + const query = i !== -1 ? args.path.slice(i + 1) : undefined; + + return { + path: filepath, + namespace: "raw", + pluginData: { + fullPath: path.resolve(args.resolveDir, filepath), + query, + }, + }; + }); + + build.onLoad({ filter: /.*/, namespace: "raw" }, args => { + const { fullPath, query } = args.pluginData; + let filePath = fullPath; if (fs.existsSync(filePath) && fs.lstatSync(filePath).isDirectory()) { filePath = path.join(filePath, "index"); } if (!fs.existsSync(filePath)) { - for (const e of ext) { - if (fs.existsSync(`${filePath}.${e}`)) { - filePath += `.${e}`; - break; - } + const resolved = ext.find(e => fs.existsSync(`${filePath}.${e}`)); + if (resolved) { + filePath += `.${resolved}`; } } if (!fs.existsSync(filePath)) { throw new Error( - /* v8 ignore next */ - `File not found: ${args.pluginData}\nChecked extensions: ${ext.join(", ")}. You can customize this using { ext: [...] }.`, - /* v8 ignore next */ + `File not found: ${fullPath}\nChecked extensions: ${ext.join(", ")}.\nYou can customize extensions list using { ext: [...] }.`, ); } - return { contents: fs.readFileSync(filePath, "utf8"), loader: "text" }; + const buffer = fs.readFileSync(filePath); + const suffix = query?.toLowerCase(); + + let loader = options?.loader ?? "text"; + switch (suffix) { + case "buffer": + case "binary": + loader = "binary"; + break; + case "text": + case "file": + case "base64": + case "dataurl": + loader = suffix; + break; + case "raw": + break; + } + + return { contents: buffer, loader }; }); - if (options?.textExtensions?.length) { - build.onLoad( - { - filter: new RegExp( - `\.(${options.textExtensions.map(e => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})$`, - ), - }, - args => ({ - contents: fs.readFileSync(args.path, "utf8"), - loader: "text", - }), + if (options?.customLoaders) { + const customLoaderKeys = Object.keys(options.customLoaders).sort( + (a, b) => b.length - a.length, + ); + const pattern = new RegExp( + `\\.(${customLoaderKeys + .map(e => e.replace(/^\./, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|")})$`, ); + + build.onLoad({ filter: pattern }, args => { + const path = args.path; + const loaderKey = customLoaderKeys.find(suffix => path.endsWith(suffix)); + const loader = options.customLoaders?.[loaderKey ?? ""]; + if (!loader) return; + + const buffer = fs.readFileSync(path); + return { contents: buffer, loader }; + }); } }, });