Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/oxlint/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/node_modules/
/dist/
/dist-pkg-plugins/
*.node
19 changes: 18 additions & 1 deletion apps/oxlint/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { join } from "node:path";

const oxlintDirPath = join(import.meta.dirname, ".."),
srcDirPath = join(oxlintDirPath, "src-js"),
distDirPath = join(oxlintDirPath, "dist");
distDirPath = join(oxlintDirPath, "dist"),
distPkgPluginsDirPath = join(oxlintDirPath, "dist-pkg-plugins"),
pkgPluginsDirPath = join(oxlintDirPath, "../../npm/oxlint-plugins");

// Build with tsdown
console.log("Building with tsdown...");
Expand All @@ -24,4 +26,19 @@ for (const filename of readdirSync(srcDirPath)) {
copyFileSync(srcPath, join(distDirPath, filename));
}

// Copy files to `@oxlint/plugins` package
console.log("Moving files to `@oxlint/plugins` package...");

const files = [
{ src: "esm/index.js", dest: "index.js" },
{ src: "esm/index.d.ts", dest: "index.d.ts" },
{ src: "cjs/index.cjs", dest: "index.cjs" },
];

for (const { src, dest } of files) {
copyFileSync(join(distPkgPluginsDirPath, src), join(pkgPluginsDirPath, dest));
}

rmSync(distPkgPluginsDirPath, { recursive: true });

console.log("Build complete!");
53 changes: 46 additions & 7 deletions apps/oxlint/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const CONFORMANCE = isEnabled(env.CONFORMANCE);
// This is the build used in tests.
const DEBUG = CONFORMANCE || isEnabled(env.DEBUG);

// Base config
const commonConfig = defineConfig({
platform: "node",
target: "node20",
Expand All @@ -30,9 +31,36 @@ const commonConfig = defineConfig({
inlineOnly: false,
});

// Minification options.
// At present only compress syntax.
// Don't mangle identifiers or remove whitespace, so `dist` code remains somewhat readable.
const minifyConfig = {
compress: { keepNames: { function: true, class: true } },
mangle: false,
codegen: { removeWhitespace: false },
};

// Base config for `@oxlint/plugins` package.
// "node12" target to match `engines` field of last ESLint 8 release (8.57.1).
const pluginsPkgConfig = defineConfig({
...commonConfig,
entry: {
index: "src-js/plugins.ts",
},
target: "node12",
minify: minifyConfig,
define: {
DEBUG: "false",
CONFORMANCE: "false",
},
});

// Plugins.
// Only remove debug assertions in release build.
const plugins = [createReplaceGlobalsPlugin()];
if (!DEBUG) plugins.push(createReplaceAssertsPlugin());

// All build configs
export default defineConfig([
// Main build
{
Expand All @@ -44,13 +72,7 @@ export default defineConfig([
"./oxlint.*.node",
"@oxlint/*",
],
// At present only compress syntax.
// Don't mangle identifiers or remove whitespace, so `dist` code remains somewhat readable.
minify: {
compress: { keepNames: { function: true, class: true } },
mangle: false,
codegen: { removeWhitespace: false },
},
minify: minifyConfig,
dts: true,
attw: { profile: "esm-only" },
define: {
Expand All @@ -63,6 +85,7 @@ export default defineConfig([
experimental: { nativeMagicString: true },
},
},

// TypeScript.
// Bundled separately and lazy-loaded, as it's a lot of code.
// Only used for tokens APIs.
Expand All @@ -74,6 +97,22 @@ export default defineConfig([
// Minification halves the size of the bundle.
minify: true,
},

// `@oxlint/plugins` package.
// Dual package - both ESM and CommonJS.
// `scripts/build.ts` moves built files in `dist-pkg-plugins` to `npm/oxlint-plugins`.
{
...pluginsPkgConfig,
outDir: "dist-pkg-plugins/esm",
format: "esm",
dts: true,
},
{
...pluginsPkgConfig,
outDir: "dist-pkg-plugins/cjs",
format: "commonjs",
dts: false,
},
]);

/**
Expand Down
11 changes: 11 additions & 0 deletions npm/oxlint-plugins/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

All notable changes to this package will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0).

## [1.42.0] - 2026-01-26

### 🚀 Features

- Initial release of `@oxlint/plugins` package
95 changes: 95 additions & 0 deletions npm/oxlint-plugins/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# @oxlint/plugins

Plugin utilities for [Oxlint](https://oxc.rs/docs/guide/usage/linter/js-plugins).

This package provides optional functions to assist in creating Oxlint JS plugins and rules.

## Installation

```bash
npm install @oxlint/plugins
```

## Usage

### Define functions

Use `definePlugin` and `defineRule` if authoring your plugin in TypeScript for type safety.

```typescript
import { definePlugin, defineRule } from "@oxlint/plugins";

const rule = defineRule({
create(context) {
return {
Program(node) {
// Rule logic here
},
};
},
});

export default definePlugin({
meta: { name: "oxlint-plugin-amazing" },
rules: { amazing: rule },
});
```

### Types

This package also includes types for plugins and rules.

```typescript
import type { Context, Rule, ESTree } from "@oxlint/plugins";

const rule: Rule = {
create(context: Context) {
return {
Program(node: ESTree.Program) {
// Rule logic here
},
};
},
};
```

### ESLint compatibility

If your plugin uses Oxlint's [alternative `createOnce` API](https://oxc.rs/docs/guide/usage/linter/js-plugins#alternative-api),
use `eslintCompatPlugin` to convert the plugin so it will also work with ESLint.

```typescript
import { eslintCompatPlugin } from "@oxlint/plugins";

const rule = {
createOnce(context) {
return {
Program(node) {
// Rule logic here
},
};
},
};

export default eslintCompatPlugin({
meta: { name: "oxlint-plugin-amazing" },
rules: { amazing: rule },
});
```

## Node.js version

This package requires Node.js 12.22.0+, 14.17.0+, 16.0.0+, or later.
This matches the minimum Node.js version required by ESLint 8.

This package provides both ESM and CommonJS entry points.

So a plugin which depends on `@oxlint/plugins` can be:

- Used with any version of Oxlint.
- Used with ESLint 8+.
- Published as either ESM or CommonJS.

## Docs

For full documentation, see [Oxlint JS Plugins docs](https://oxc.rs/docs/guide/usage/linter/js-plugins).
102 changes: 102 additions & 0 deletions npm/oxlint-plugins/index.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
function definePlugin(plugin) {
return plugin;
}
function defineRule(rule) {
return rule;
}
const EMPTY_VISITOR = {};
function eslintCompatPlugin(plugin) {
if (typeof plugin != "object" || !plugin) throw Error("Plugin must be an object");
let { rules } = plugin;
if (typeof rules != "object" || !rules) throw Error("Plugin must have an object as `rules` property");
for (let ruleName in rules) Object.hasOwn(rules, ruleName) && convertRule(rules[ruleName]);
return plugin;
}
function convertRule(rule) {
if (typeof rule != "object" || !rule) throw Error("Rule must be an object");
if ("create" in rule) return;
let context = null, visitor, beforeHook;
rule.create = (eslintContext) => (context === null && ({context, visitor, beforeHook} = createContextAndVisitor(rule)), Object.defineProperties(context, {
id: { value: eslintContext.id },
options: { value: eslintContext.options },
report: { value: eslintContext.report }
}), Object.setPrototypeOf(context, Object.getPrototypeOf(eslintContext)), beforeHook !== null && beforeHook() === !1 ? EMPTY_VISITOR : visitor);
}
const FILE_CONTEXT = Object.freeze({
get filename() {
throw Error("Cannot access `context.filename` in `createOnce`");
},
getFilename() {
throw Error("Cannot call `context.getFilename` in `createOnce`");
},
get physicalFilename() {
throw Error("Cannot access `context.physicalFilename` in `createOnce`");
},
getPhysicalFilename() {
throw Error("Cannot call `context.getPhysicalFilename` in `createOnce`");
},
get cwd() {
throw Error("Cannot access `context.cwd` in `createOnce`");
},
getCwd() {
throw Error("Cannot call `context.getCwd` in `createOnce`");
},
get sourceCode() {
throw Error("Cannot access `context.sourceCode` in `createOnce`");
},
getSourceCode() {
throw Error("Cannot call `context.getSourceCode` in `createOnce`");
},
get languageOptions() {
throw Error("Cannot access `context.languageOptions` in `createOnce`");
},
get settings() {
throw Error("Cannot access `context.settings` in `createOnce`");
},
extend(extension) {
return Object.freeze(Object.assign(Object.create(this), extension));
},
get parserOptions() {
throw Error("Cannot access `context.parserOptions` in `createOnce`");
},
get parserPath() {
throw Error("Cannot access `context.parserPath` in `createOnce`");
}
});
function createContextAndVisitor(rule) {
let { createOnce } = rule;
if (createOnce == null) throw Error("Rules must define either a `create` or `createOnce` method");
if (typeof createOnce != "function") throw Error("Rule `createOnce` property must be a function");
let context = Object.create(FILE_CONTEXT, {
id: {
value: "",
enumerable: !0,
configurable: !0
},
options: {
value: null,
enumerable: !0,
configurable: !0
},
report: {
value: null,
enumerable: !0,
configurable: !0
}
}), { before: beforeHook, after: afterHook, ...visitor } = createOnce.call(rule, context);
if (beforeHook === void 0) beforeHook = null;
else if (beforeHook !== null && typeof beforeHook != "function") throw Error("`before` property of visitor must be a function if defined");
if (afterHook != null) {
if (typeof afterHook != "function") throw Error("`after` property of visitor must be a function if defined");
let programExit = visitor["Program:exit"];
visitor["Program:exit"] = programExit == null ? (_node) => afterHook() : (node) => {
programExit(node), afterHook();
};
}
return {
context,
visitor,
beforeHook
};
}
exports.definePlugin = definePlugin, exports.defineRule = defineRule, exports.eslintCompatPlugin = eslintCompatPlugin;
Loading
Loading