create-neoink 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +54 -0
  2. package/package.json +22 -0
  3. package/src/index.ts +365 -0
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # create-neoink
2
+
3
+ Scaffold a Neovim plugin built with [neoink](https://github.com/NicholasZolton/neoink)
4
+ (React → Neovim).
5
+
6
+ ```bash
7
+ bun create neoink my-plugin
8
+ # or
9
+ bunx create-neoink my-plugin
10
+ ```
11
+
12
+ Generates a self-contained plugin project:
13
+
14
+ ```
15
+ my-plugin/
16
+ host.tsx a starter React app (a small counter)
17
+ plugin/my-plugin.lua registers :MyPlugin, jobstarts the built host
18
+ package.json @neoinkjs/* + react deps, build scripts
19
+ tsconfig.json .gitignore .luarc.json README.md
20
+ ```
21
+
22
+ Then:
23
+
24
+ ```bash
25
+ cd my-plugin
26
+ bun install
27
+ bun run build # -> dist/host (standalone binary; nothing needed at runtime)
28
+ # in Neovim: :MyPlugin
29
+ ```
30
+
31
+ Edit `host.tsx` — it's an ordinary React app; neoink renders it into a Neovim
32
+ window. Install it with any plugin manager (the generated README has a lazy.nvim
33
+ snippet).
34
+
35
+ ## Developing against a local neoink checkout
36
+
37
+ Before the `@neoinkjs/*` packages are published (or to hack on neoink itself),
38
+ point the generated project at a checkout:
39
+
40
+ ```bash
41
+ bunx create-neoink my-plugin --local /path/to/neoink
42
+ ```
43
+
44
+ This wires `tsconfig.json` paths to the checkout's source and symlinks a single
45
+ React copy (so hooks don't throw "invalid hook call"), instead of depending on
46
+ the published packages.
47
+
48
+ ## Options
49
+
50
+ - `--name <name>` — plugin/command base name (default: the target directory
51
+ name). `my-plugin` → command `:MyPlugin`.
52
+ - `--local <path>` — wire to a neoink checkout instead of the registry.
53
+
54
+ Requires [Bun](https://bun.sh) to build and run.
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "create-neoink",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a Neovim plugin built with neoink (React → Neovim).",
5
+ "license": "MIT",
6
+ "author": "Nicholas Zolton",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/NicholasZolton/neoink.git",
10
+ "directory": "packages/create-neoink"
11
+ },
12
+ "homepage": "https://github.com/NicholasZolton/neoink#readme",
13
+ "keywords": ["neovim", "nvim", "react", "tui", "neoink", "create", "scaffold"],
14
+ "type": "module",
15
+ "bin": {
16
+ "create-neoink": "./src/index.ts"
17
+ },
18
+ "files": ["src"],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ }
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * create-neoink — scaffold a Neovim plugin built with neoink.
4
+ *
5
+ * bun create neoink <dir> # wired to the published @neoinkjs/* packages
6
+ * bunx create-neoink <dir>
7
+ * bunx create-neoink <dir> --local /path/to/neoink # wired to a local checkout
8
+ *
9
+ * Generates a self-contained plugin project: a starter `host.tsx`, the Neovim
10
+ * `plugin/<name>.lua` that jobstarts the built host, build scripts, tsconfig,
11
+ * and a README. `--local` points it at a neoink checkout (tsconfig paths + a
12
+ * React symlink) for developing before the packages are on npm; otherwise it
13
+ * depends on `@neoinkjs/*` from the registry.
14
+ */
15
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
16
+ import { basename, resolve, join, relative, dirname } from "node:path";
17
+
18
+ interface Args {
19
+ dir: string;
20
+ name: string;
21
+ local?: string; // absolute path to a neoink checkout
22
+ }
23
+
24
+ function parseArgs(argv: string[]): Args {
25
+ let dir: string | undefined;
26
+ let name: string | undefined;
27
+ let local: string | undefined;
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i]!;
30
+ if (a === "--local") local = resolve(argv[++i] ?? "");
31
+ else if (a === "--name") name = argv[++i];
32
+ else if (!a.startsWith("-") && dir === undefined) dir = a;
33
+ }
34
+ if (dir === undefined) {
35
+ console.error("usage: create-neoink <dir> [--name <plugin-name>] [--local <path-to-neoink>]");
36
+ process.exit(1);
37
+ }
38
+ const pkgName = (name ?? basename(dir)).replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase();
39
+ return { dir: resolve(dir), name: pkgName, local };
40
+ }
41
+
42
+ /** kebab/space -> PascalCase, for the `:Command` name. */
43
+ function pascal(s: string): string {
44
+ return s
45
+ .split(/[-_\s]+/)
46
+ .filter(Boolean)
47
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
48
+ .join("");
49
+ }
50
+
51
+ function main(): void {
52
+ const args = parseArgs(process.argv.slice(2));
53
+ const command = pascal(args.name);
54
+ const luaName = args.name;
55
+
56
+ if (existsSync(args.dir)) {
57
+ console.error(`refusing to overwrite existing path: ${args.dir}`);
58
+ process.exit(1);
59
+ }
60
+ mkdirSync(join(args.dir, "plugin"), { recursive: true });
61
+
62
+ const write = (rel: string, content: string): void => {
63
+ const p = join(args.dir, rel);
64
+ mkdirSync(dirname(p), { recursive: true });
65
+ writeFileSync(p, content);
66
+ };
67
+
68
+ // ── package.json ──────────────────────────────────────────────────────────
69
+ const scripts = args.local
70
+ ? {
71
+ // Absolute target: --local is inherently machine-specific (the checkout
72
+ // path is baked in), and it avoids the off-by-one a relative path from
73
+ // node_modules/ invites. Keeps a single React instance shared with the
74
+ // checkout so hooks don't throw "invalid hook call".
75
+ "link-react": "rm -rf node_modules/react && ln -s " + join(args.local, "node_modules/react") + " node_modules/react",
76
+ build: "bun run link-react && bun build ./host.tsx --compile --outfile dist/host",
77
+ "build:js": "bun run link-react && bun build ./host.tsx --target=bun --outfile dist/host.js",
78
+ }
79
+ : {
80
+ build: "bun build ./host.tsx --compile --outfile dist/host",
81
+ "build:js": "bun build ./host.tsx --target=bun --outfile dist/host.js",
82
+ };
83
+ const pkg: Record<string, unknown> = {
84
+ name: args.name,
85
+ version: "0.0.0",
86
+ private: true,
87
+ type: "module",
88
+ scripts,
89
+ devDependencies: {
90
+ "@types/bun": "latest",
91
+ "@types/react": "^19.0.0",
92
+ "@typescript/native-preview": "latest",
93
+ },
94
+ };
95
+ if (!args.local) {
96
+ pkg.dependencies = {
97
+ "@neoinkjs/core": "^0.1.0",
98
+ "@neoinkjs/components": "^0.1.0",
99
+ "@neoinkjs/plugin": "^0.1.0",
100
+ react: "^19.0.0",
101
+ };
102
+ }
103
+ write("package.json", JSON.stringify(pkg, null, 2) + "\n");
104
+
105
+ // ── tsconfig.json ─────────────────────────────────────────────────────────
106
+ const paths = args.local
107
+ ? {
108
+ "@neoinkjs/*": [relative(args.dir, join(args.local, "packages")) + "/*/src/index.ts"],
109
+ }
110
+ : undefined;
111
+ const tsconfig = {
112
+ compilerOptions: {
113
+ lib: ["ESNext"],
114
+ target: "ESNext",
115
+ module: "ESNext",
116
+ moduleResolution: "bundler",
117
+ jsx: "react-jsx",
118
+ jsxImportSource: "react",
119
+ types: ["bun", "react"],
120
+ strict: true,
121
+ skipLibCheck: true,
122
+ allowImportingTsExtensions: true,
123
+ noEmit: true,
124
+ ...(paths ? { paths } : {}),
125
+ },
126
+ include: ["host.tsx", "src", "test"],
127
+ };
128
+ write("tsconfig.json", JSON.stringify(tsconfig, null, 2) + "\n");
129
+
130
+ // ── host.tsx (starter app) ────────────────────────────────────────────────
131
+ write(
132
+ "host.tsx",
133
+ `/**
134
+ * ${args.name} — neoink plugin host. Mounts <App/> into a Neovim window with
135
+ * @neoinkjs/plugin's mount(). Built to dist/host (see package.json), which
136
+ * plugin/${luaName}.lua jobstarts. The import.meta.main guard keeps mount()
137
+ * from firing when this module is merely imported (e.g. by a test).
138
+ */
139
+ import React, { useState } from "react";
140
+ import { mount } from "@neoinkjs/plugin";
141
+ import { useApp, useInput, type Key } from "@neoinkjs/core";
142
+ import { Box, Text } from "@neoinkjs/components";
143
+
144
+ export function App(): React.ReactElement {
145
+ const [count, setCount] = useState(0);
146
+ const { exit } = useApp();
147
+
148
+ useInput((input: string, key: Key) => {
149
+ if (input === "+" || input === "k" || key.upArrow) setCount((c) => c + 1);
150
+ else if (input === "-" || input === "j" || key.downArrow) setCount((c) => c - 1);
151
+ else if (input === "q" || key.escape) exit();
152
+ });
153
+
154
+ return (
155
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
156
+ <Text bold color="cyan">${command}</Text>
157
+ <Box marginTop={1}>
158
+ <Text>count: <Text bold color="yellow">{String(count)}</Text></Text>
159
+ </Box>
160
+ <Box marginTop={1}>
161
+ <Text color="gray">j/k or +/- to change · q to quit</Text>
162
+ </Box>
163
+ </Box>
164
+ );
165
+ }
166
+
167
+ if (import.meta.main) {
168
+ const handle = await mount(<App />, { style: "float" });
169
+ await handle.waitUntilExit();
170
+ handle.unmount();
171
+ process.exit(0);
172
+ }
173
+ `,
174
+ );
175
+
176
+ // ── plugin/<name>.lua ─────────────────────────────────────────────────────
177
+ const luaPath = args.local
178
+ ? `-- neoink is a local checkout; point package.path at its bundled Lua.
179
+ local neoink_lua = "${join(args.local, "packages/plugin/lua")}"`
180
+ : `-- neoink's Lua bootstrap ships inside the installed @neoinkjs/plugin package.
181
+ local neoink_lua = here .. "/node_modules/@neoinkjs/plugin/lua"`;
182
+ write(
183
+ `plugin/${luaName}.lua`,
184
+ `-- Neovim entry point for ${args.name}. Registers :${command}, which jobstarts
185
+ -- this project's build output (dist/host if compiled, else \`bun run
186
+ -- dist/host.js\`) over neoink's RPC stdio channel.
187
+ local this_file = debug.getinfo(1, "S").source:sub(2)
188
+ local here = vim.fn.fnamemodify(this_file, ":h:h") -- project root
189
+
190
+ ${luaPath}
191
+ if not package.path:find(neoink_lua, 1, true) then
192
+ package.path = neoink_lua .. "/?.lua;" .. neoink_lua .. "/?/init.lua;" .. package.path
193
+ end
194
+
195
+ vim.api.nvim_create_user_command("${command}", function()
196
+ require("neoink").start({ dir = here })
197
+ end, {
198
+ desc = "Open ${args.name} (built with neoink)",
199
+ })
200
+ `,
201
+ );
202
+
203
+ // ── .gitignore / .luarc.json / README ─────────────────────────────────────
204
+ write(".gitignore", "dist/\nnode_modules/\n");
205
+ write(
206
+ ".luarc.json",
207
+ JSON.stringify(
208
+ {
209
+ $schema: "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
210
+ "runtime.version": "LuaJIT",
211
+ "diagnostics.globals": ["vim"],
212
+ "workspace.checkThirdParty": false,
213
+ },
214
+ null,
215
+ 2,
216
+ ) + "\n",
217
+ );
218
+
219
+ const installNote = args.local
220
+ ? `> Wired to a local neoink checkout at \`${args.local}\` (tsconfig paths + a
221
+ > React symlink via the \`link-react\` script). Publish neoink and switch to the
222
+ > \`@neoinkjs/*\` dependencies for a normal install.`
223
+ : `Depends on the published \`@neoinkjs/*\` packages plus \`react\`.`;
224
+ write(
225
+ "README.md",
226
+ `# ${args.name}
227
+
228
+ A Neovim plugin built with [neoink](https://github.com/NicholasZolton/neoink).
229
+ Run \`:${command}\` to open it.
230
+
231
+ ${installNote}
232
+
233
+ ## Develop
234
+
235
+ \`\`\`bash
236
+ bun install
237
+ bunx tsgo --noEmit # typecheck
238
+ bun run build # -> dist/host (standalone binary)
239
+ \`\`\`
240
+
241
+ ## Install (lazy.nvim)
242
+
243
+ \`\`\`lua
244
+ {
245
+ dir = "${args.dir}", -- or "you/${args.name}" once pushed to GitHub
246
+ name = "${args.name}",
247
+ build = "bun install && bun run build",
248
+ cmd = "${command}",
249
+ }
250
+ \`\`\`
251
+
252
+ Edit \`host.tsx\` — it's a normal React app; neoink renders it into a Neovim
253
+ window. \`plugin/${luaName}.lua\` registers the \`:${command}\` command.
254
+
255
+ ## Zero-dependency installs (prebuilt binaries)
256
+
257
+ By default \`build\` compiles the host locally (users need Bun). To let users
258
+ install with **nothing** installed:
259
+
260
+ 1. Push a version tag (\`git tag v0.1.0 && git push --tags\`).
261
+ \`.github/workflows/release.yml\` cross-compiles the host for macOS/Linux
262
+ (arm64 + x64) and attaches the binaries to the GitHub Release.
263
+ 2. Point your plugin manager's build step at the downloader and set your repo:
264
+
265
+ \`\`\`lua
266
+ build = "NEOINK_PLUGIN_REPO=you/${args.name} bun scripts/fetch-host.ts",
267
+ \`\`\`
268
+
269
+ \`scripts/fetch-host.ts\` downloads the binary matching the user's platform
270
+ from your latest release, and falls back to a local \`bun --compile\` if the
271
+ download fails (no release yet, offline, or an unsupported platform).
272
+ `,
273
+ );
274
+
275
+ // ── Phase 3: prebuilt-binary CI + download flow (opt-in) ──────────────────
276
+ // A release workflow cross-compiles the host for every platform and attaches
277
+ // the binaries to a GitHub Release; `scripts/fetch-host.ts` downloads the one
278
+ // matching the user's platform (falling back to a local compile). Zero-dep
279
+ // installs when the author opts in; see the generated README.
280
+ write(
281
+ ".github/workflows/release.yml",
282
+ `name: release
283
+ on:
284
+ push:
285
+ tags: ["v*"]
286
+ permissions:
287
+ contents: write
288
+ jobs:
289
+ release:
290
+ runs-on: ubuntu-latest
291
+ steps:
292
+ - uses: actions/checkout@v4
293
+ - uses: oven-sh/setup-bun@v2
294
+ - run: bun install
295
+ - name: cross-compile host for every platform
296
+ run: |
297
+ for t in bun-darwin-arm64 bun-darwin-x64 bun-linux-x64 bun-linux-arm64; do
298
+ bun build ./host.tsx --compile --target=$t --outfile "host-$t"
299
+ done
300
+ - uses: softprops/action-gh-release@v2
301
+ with:
302
+ files: host-bun-*
303
+ `,
304
+ );
305
+ write(
306
+ "scripts/fetch-host.ts",
307
+ `#!/usr/bin/env bun
308
+ /**
309
+ * Fetch the prebuilt host binary for this platform from the plugin's latest
310
+ * GitHub release, or fall back to a local \`bun build --compile\`. Point a
311
+ * plugin manager's build step here (e.g. lazy.nvim \`build = "bun
312
+ * scripts/fetch-host.ts"\`) and set NEOINK_PLUGIN_REPO="you/${args.name}" for
313
+ * zero-dependency installs; with no repo set (or on an unsupported platform,
314
+ * or offline) it just compiles locally, so it always produces dist/host.
315
+ */
316
+ import { mkdirSync, chmodSync } from "node:fs";
317
+
318
+ const REPO = process.env.NEOINK_PLUGIN_REPO ?? "";
319
+
320
+ function target(): string | null {
321
+ const p = process.platform;
322
+ const a = process.arch;
323
+ if (p === "darwin" && a === "arm64") return "bun-darwin-arm64";
324
+ if (p === "darwin" && a === "x64") return "bun-darwin-x64";
325
+ if (p === "linux" && a === "x64") return "bun-linux-x64";
326
+ if (p === "linux" && a === "arm64") return "bun-linux-arm64";
327
+ return null;
328
+ }
329
+
330
+ function localBuild(): never {
331
+ console.log("building host locally (bun --compile)…");
332
+ const proc = Bun.spawnSync(["bun", "build", "./host.tsx", "--compile", "--outfile", "dist/host"], {
333
+ stdout: "inherit",
334
+ stderr: "inherit",
335
+ });
336
+ process.exit(proc.exitCode ?? 1);
337
+ }
338
+
339
+ const t = target();
340
+ if (!REPO || t === null) localBuild();
341
+
342
+ mkdirSync("dist", { recursive: true });
343
+ const url = \`https://github.com/\${REPO}/releases/latest/download/host-\${t}\`;
344
+ try {
345
+ const res = await fetch(url);
346
+ if (!res.ok) throw new Error(\`HTTP \${res.status}\`);
347
+ await Bun.write("dist/host", res);
348
+ chmodSync("dist/host", 0o755);
349
+ console.log(\`downloaded prebuilt host (\${t})\`);
350
+ } catch (err) {
351
+ console.log(\`prebuilt download failed (\${String(err)}); building locally\`);
352
+ localBuild();
353
+ }
354
+ `,
355
+ );
356
+
357
+ console.log(`\n✓ created ${args.name} at ${args.dir}\n`);
358
+ console.log("next:");
359
+ console.log(` cd ${relative(process.cwd(), args.dir) || args.dir}`);
360
+ console.log(" bun install");
361
+ console.log(" bun run build");
362
+ console.log(` # then in Neovim: :${command}\n`);
363
+ }
364
+
365
+ main();