npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@neoinkjs/plugin

v0.1.5

Published

Mount a neoink React app into a Neovim float/split/tab; includes the stdio host and Lua bootstrap.

Readme

@neoinkjs/plugin

Turn a neoink app into a Neovim plugin: one Lua command starts your Bun process, one JS call mounts your component into a window, and both ends talk over the RPC channel Neovim's own jobstart already gives you.

examples/plugin is a full working plugin built with this package. Install it, run :NeoinkDemo, and read its source alongside this doc.

The jobstart-rpc model

A neoink plugin has two halves:

  • Lua, loaded by your plugin manager, registers a user command.
  • JS/TS ("the host"), a Bun process, holds your React component tree.

The command's callback calls require("neoink").start({ dir = ... }), which runs vim.fn.jobstart(cmd, { rpc = true }) on your build output. rpc = true is what makes this work: Neovim treats the job's stdin/stdout as a msgpack-RPC channel instead of a plain pipe, so the spawned process can call back into the same Neovim that spawned it. No socket, no second instance, no separate connect step.

On the JS side, @neoinkjs/plugin's mount() calls connectStdio() (from @neoinkjs/rpc) to pick up that same channel from the host's own process.stdin/process.stdout, then does the buffer/window/keymap setup examples/live.tsx does by hand and renders your component into it.

nvim (:YourCommand)
  └─ jobstart(cmd, { rpc = true })          Lua: require("neoink").start
       └─ host process (Bun)
            └─ connectStdio()                JS: @neoinkjs/rpc
                 └─ mount(<App/>)            JS: @neoinkjs/plugin
                      └─ your component, rendered into a real nvim buffer

Nothing here is neoink-specific RPC. It's the same jobstart/rpc mechanism any Neovim plugin can use to talk to an external process. neoink just gives you a typed client and a one-call mount on top of it.

JS/TS API

mount(element, options?)

import { mount } from "@neoinkjs/plugin";

const handle = await mount(<App />, { style: "float" });
await handle.waitUntilExit();
handle.unmount();

Connects to the host's own stdio (via connectStdio()), creates a scratch buffer, shows it per options.style, wires up interactive input (useInput works exactly as it does anywhere else in neoink), and renders element into it. This is the entry point every plugin host calls: it's what examples/plugin/host.tsx uses.

MountOptions:

| Option | Type | Default | Meaning | |---|---|---|---| | style | "float" \| "split" \| "tab" | "float" | How the scratch buffer is shown | | width | number | derived | Float: window width. Split/tab: rendered content width | | height | number | derived | Float: window height. Split: the split window's height. Unused by "tab" | | row | number | centered | Float only: window's top row | | col | number | centered | Float only: window's left column |

  • "float" opens a centered floating window (roughly 80%/60% of the editor by default), the same style examples/live.tsx uses.
  • "split" opens a horizontal split below the current window (nvim_open_win({ split: "below" })).
  • "tab" opens a new tab page and shows the scratch buffer in its one window.

MountHandle:

  • waitUntilExit(): Promise<void>: resolves when the app exits, via useApp().exit() inside your component or a caller's own unmount(). Rejects if a component calls exit(error). Also resolves if the user closes the mount's window directly (:q, <C-w>c, ...) instead of your app's own quit key — a one-shot WinClosed autocmd mount() installs notifies the host either way, so this never hangs waiting on a window that no longer exists.
  • unmount(): void: tears down the render, closes the window/tab, deletes the scratch buffer, and closes the RPC channel. Safe to call more than once or on a partially-set-up mount. This is not a full process teardown — it closes the nvim-side resources this mount owns, but the host's own Bun process keeps running until it exits on its own. That's why every example above ends with process.exit(0) after await handle.waitUntilExit()/handle.unmount(), mirroring examples/plugin/host.tsx: without it, the host process would just sit there, connected to a channel with nothing left to do.

mountWith(channel, element, options?)

Same as mount, but takes an already-open Channel instead of calling connectStdio() itself. mount() is just mountWith(connectStdio(), ...).

Use mountWith directly when you already have a Channel from somewhere else: attachToNvim() or spawnEmbeddedNvim() from @neoinkjs/rpc, for example in a test that drives a mount against a real embedded Neovim without spawning a second host process (see packages/plugin/test/mount.integration.test.tsx).

mountWith takes ownership of the channel for that mount's lifetime. unmount() closes it. Don't reuse one Channel across multiple mountWith calls, or one mount's teardown closes the channel out from under another, still-live mount.

connectStdio() (from @neoinkjs/rpc)

The channel mount() uses under the hood. Reads msgpack-RPC off process.stdin, writes it to process.stdout: the exact stream pair jobstart(cmd, { rpc = true }) wires up for its spawned process. Same Channel shape as attachToNvim() and spawnEmbeddedNvim(), so anything written against one works against all three.

import { connectStdio } from "@neoinkjs/rpc";

const channel = connectStdio();
// channel.client is a full RpcClient, the same one `attachToNvim` returns

You won't normally call this yourself; mount() already does it.

Lua API

require("neoink") (packages/plugin/lua/neoink/init.lua) is the Lua half: starting and stopping the host job, and resolving what command to run it with.

start(opts)

require("neoink").start({ dir = "/path/to/your/plugin" })

opts:

  • dir (required): your plugin's root directory. Its dist/ holds the build output start looks for.
  • cmd (optional): an explicit command list. When given, start runs it as-is and skips dist/ lookup entirely.
  • on_exit (optional): called when the host process exits (in addition to start's own bookkeeping).

start is idempotent: if a job is already running, it returns that job's id without spawning a second one. Call stop() first if you want a fresh process.

It also registers a VimLeavePre autocmd (once, regardless of how many times start runs) that calls stop(), so quitting Neovim doesn't leave an orphaned host process behind.

stop()

require("neoink").stop()

Stops the running host job, if any. Safe to call with nothing running, or more than once in a row.

Cmd resolution

When opts.cmd isn't given, start resolves a command from opts.dir:

  1. <dir>/dist/host: a compiled binary (bun build --compile). Used directly if it exists, no bun needed at runtime.
  2. Otherwise, bun run <dir>/dist/host.js: a plain JS bundle. This is the fallback for a dev checkout or an install that skipped the compile step.

This is the same convention examples/plugin's bun run build / bun run build:js scripts produce output for. See below.

Write your own plugin

The shape is always the same four pieces. examples/plugin is this exact layout, working end to end. Copy it as your starting point.

1. host.tsx: your app, mounted with mount():

import React from "react";
import { mount } from "@neoinkjs/plugin";
import { Box, Text } from "@neoinkjs/components";

function App() {
  return (
    <Box borderStyle="round" padding={1}>
      <Text>hello from your plugin</Text>
    </Box>
  );
}

const handle = await mount(<App />);
await handle.waitUntilExit();
handle.unmount();
process.exit(0);

2. lua/neoink/init.lua: vendor @neoinkjs/plugin's Lua bootstrap into your own plugin.

require("neoink") (the start/stop API documented above) has to actually be on Neovim's runtimepath for your users — it isn't pulled in automatically just because your plugin depends on the @neoinkjs/plugin npm package. Copy this repository's packages/plugin/lua/neoink/init.lua into your own plugin's lua/neoink/init.lua. Any plugin manager (lazy.nvim, packer, vim-plug, ...) already puts everything under a plugin's lua/ directory on runtimepath, so once it's vendored there, require("neoink") resolves the normal way any Neovim plugin's Lua module does — no package.path surgery needed.

(examples/plugin — this repo's own demo plugin — instead prepends packages/plugin/lua to package.path by hand. That's an in-repo-only shim so the example can run against this checkout's own, unpublished @neoinkjs/plugin without a build/publish step; see examples/plugin/README.md. Don't copy that shim into a real, standalone plugin — vendor the file as described above instead. create-neoink will automate this vendoring step in a future release; for now, copy it by hand.)

3. plugin/your-plugin.lua: registers a command that starts it:

local here = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h")

vim.api.nvim_create_user_command("YourPlugin", function()
  require("neoink").start({ dir = here })
end, {})

4. Build it:

bun run build     # dist/host     compiled binary, no bun needed to run it
bun run build:js  # dist/host.js  plain JS bundle, run via `bun run`

Ship build (or build:js) as your plugin manager's install/build step. examples/plugin/README.md has a working lazy.nvim snippet. start prefers the compiled binary and falls back to bun run dist/host.js automatically, so shipping either output (or both) just works.

For the full picture: a real <App/> with useInput/useState, the exact Lua file that resolves its own directory, package.json build scripts, and an install snippet, read examples/plugin/host.tsx, examples/plugin/plugin/neoink-demo.lua, and examples/plugin/README.md.