Authoring a plugin
A plugin is a single object that satisfies the Plugin contract from @omnimod/core. Use definePlugin for type inference.
import { definePlugin, walk } from "@omnimod/core";
export default definePlugin({
name: "rename-foo-to-bar",
include: ["**/*.{ts,tsx}"],
transform(file) {
walk(file.program, (node) => {
if (node.type === "Identifier" && node.name === "foo") {
file.magic.update(node.start, node.end, "bar");
}
});
},
});That's a complete, runnable plugin. Run it with omnimod run ./rename-foo-to-bar.ts "src/**/*.ts" --write.
The Plugin contract
interface Plugin<Options = Record<string, unknown>, State = unknown> {
name: string;
description?: string;
include?: string[]; // globs, relative to the run root
exclude?: string[];
createState?(): State; // initial shared store (default {})
analyze?(file, project, options): void | Promise<void>;
transform(file, project, options): void | Promise<void>;
finalize?(project, options): EmittedFile[] | void;
}Lifecycle
run() drives a two-phase map/reduce so plugins can do cross-file work:
analyzeruns over every file first. Use it to collect facts intoproject.state(e.g. "which components are exported where").transformthen runs over every file, rewriting each using that state.finalizeruns once at the end and may return files to emit (e.g. a generated theme, a report).
Only transform is required. Skip analyze/createState for single-file transforms.
FileContext
Everything you need to inspect and rewrite one file:
interface FileContext {
path: string; // absolute path
source: string; // original, unmodified text
program: Program; // parsed oxc ESTree AST
comments: Node[];
magic: MagicString; // the edit buffer — mutate this
report(d: { message: string; severity: "info" | "warn" | "error"; line?: number }): void;
emit(file: { path: string; contents: string }): void;
}- Edit by mutating
file.magic(update,appendLeft,remove,prepend, …). oxc offsets are UTF-16, so they feed straight intomagic-string. emita new file (relative paths resolve against the run root).reporta diagnostic for the CLI and for follow-up tooling.
Return without touching file.magic to leave a file unchanged.
ProjectContext
Shared, cross-file context passed to analyze/transform/finalize:
interface ProjectContext<State> {
root: string; // absolute project root
state: State; // your reduce target
resolve(fromFile: string, specifier: string): string | null; // relative import → abs path
}Traversal
walk(node, enter, leave?) is an engine-agnostic depth-first visitor. Return "skip" from enter to avoid descending into a node's children.
walk(file.program, (node, parent, key, index) => {
if (node.type === "ImportDeclaration") return "skip";
});Helpers for CSS-in-JS plugins
@omnimod/plugin-utils provides building blocks used by the styled-components plugin — reuse them for similar migrations:
- CSS parsing:
buildPlaceholderCss,parseScss,cssToVeStyle,keyframesToVe,globalRules(postcss + interpolation placeholders). - A vanilla-extract object model:
veObject/veString/veRaw/… andserializeVeto render it as formatted TS source. ImportManagerto collect and render dedupedimportstatements.cssPropToCamelfor CSS property → camelCase.
Distribution & discovery
Name your package @omnimod/plugin-<name> or omnimod-plugin-<name> and export the plugin as the default export (or a named plugin export). Then it runs by short name:
omnimod run <name> "src/**/*.tsx" --writeTesting
Plugins are easiest to test as input → output fixtures. Write files to a temp directory, run the plugin, and assert on the result:
import { run } from "@omnimod/core";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import myPlugin from "../src/index.ts";
const dir = await mkdtemp(join(tmpdir(), "t-"));
await writeFile(join(dir, "a.ts"), "const foo = 1;\n");
const result = await run(myPlugin, { root: dir });
expect(result.changed[0].after).toBe("const bar = 1;\n");