Created
October 30, 2025 21:40
-
-
Save roman01la/b9b5cbd60bcd87551884417428e38749 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /* | |
| Generate JavaScript stubs for npm dependencies so Cursive can index them | |
| and provide CLJS completions even when node_modules is excluded. | |
| Usage: | |
| node generate-js-stubs.js --output stubs/js --depth 2 --max-props 200 --include-dev false --filter react*,lodash* | |
| */ | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const { pathToFileURL } = require("url"); | |
| function parseArgs(argv) { | |
| const args = { | |
| output: "resources/stubs", | |
| depth: 10, | |
| maxProps: 1000, | |
| includeDev: true, | |
| filter: null, | |
| packageJson: "package.json", | |
| }; | |
| for (let i = 2; i < argv.length; i++) { | |
| const a = argv[i]; | |
| const next = () => (i + 1 < argv.length ? argv[++i] : undefined); | |
| if (a === "--output") args.output = next(); | |
| else if (a === "--depth") args.depth = Number(next()); | |
| else if (a === "--max-props") args.maxProps = Number(next()); | |
| else if (a === "--include-dev") | |
| args.includeDev = /^(true|1)$/i.test(next()); | |
| else if (a === "--filter") args.filter = next(); | |
| else if (a === "--package-json") args.packageJson = next(); | |
| } | |
| return args; | |
| } | |
| function readJson(file) { | |
| return JSON.parse(fs.readFileSync(file, "utf8")); | |
| } | |
| function ensureDir(dir) { | |
| fs.mkdirSync(dir, { recursive: true }); | |
| } | |
| function isValidIdentifierStart(ch) { | |
| return /[A-Za-z_$]/.test(ch); | |
| } | |
| function isValidIdentifierChar(ch) { | |
| return /[A-Za-z0-9_$]/.test(ch); | |
| } | |
| function toIdentifier(name) { | |
| let out = ""; | |
| for (const ch of name.replace(/^@/, "").replace(/[\/]/g, "_")) { | |
| out += isValidIdentifierChar(ch) ? ch : "_"; | |
| } | |
| if (!out || !isValidIdentifierStart(out[0])) out = "_" + out; | |
| return out; | |
| } | |
| function globToRegex(glob) { | |
| // very small glob: * matches any chars, ? one char | |
| const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&"); | |
| const re = "^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"; | |
| return new RegExp(re); | |
| } | |
| function filterPkgs(pkgs, filter) { | |
| if (!filter) return pkgs; | |
| const parts = filter | |
| .split(",") | |
| .map((s) => s.trim()) | |
| .filter(Boolean); | |
| const regs = parts.map(globToRegex); | |
| return pkgs.filter((p) => regs.some((r) => r.test(p))); | |
| } | |
| async function tryLoad(pkgName, basedir) { | |
| // Try CommonJS require first; if it is ESM-only, try dynamic import. | |
| try { | |
| return { mod: require(pkgName), kind: "cjs" }; | |
| } catch (e) { | |
| // Resolve path to file for ESM import | |
| try { | |
| const resolved = require.resolve(pkgName, { paths: [basedir] }); | |
| const url = pathToFileURL(resolved).href; | |
| const ns = await import(url); | |
| // Prefer default export if present | |
| return { mod: ns && (ns.default ?? ns), kind: "esm" }; | |
| } catch (e2) { | |
| return { error: e2 }; | |
| } | |
| } | |
| } | |
| function isPlainObject(x) { | |
| return ( | |
| x && typeof x === "object" && Object.getPrototypeOf(x) === Object.prototype | |
| ); | |
| } | |
| function safeOwnKeys(obj, max) { | |
| try { | |
| const names = Object.getOwnPropertyNames(obj); | |
| return names.slice(0, max); | |
| } catch (_) { | |
| return []; | |
| } | |
| } | |
| function argNamesFor(fn) { | |
| const n = Math.max(0, Math.min(4, fn.length || 0)); | |
| return Array.from({ length: n }, (_, i) => "arg" + (i + 1)); | |
| } | |
| function enumerateEntries( | |
| value, | |
| opts, | |
| depth = 0, | |
| seen = new WeakSet(), | |
| prefix = [] | |
| ) { | |
| const entries = []; | |
| if (!value || typeof value !== "object") return entries; | |
| if (seen.has(value)) return entries; | |
| seen.add(value); | |
| if (depth >= opts.depth) return entries; | |
| const keys = safeOwnKeys(value, opts.maxProps); | |
| for (const k of keys) { | |
| let v; | |
| try { | |
| v = value[k]; | |
| } catch (_) { | |
| continue; | |
| } | |
| const pathSeg = String(k); | |
| const newPrefix = prefix.concat(pathSeg); | |
| if (typeof v === "function") { | |
| entries.push({ | |
| path: newPrefix, | |
| kind: "function", | |
| arity: Math.min(4, v.length || 0), | |
| }); | |
| } else if (v && typeof v === "object") { | |
| // record the object itself as a field to expose it, then traverse | |
| entries.push({ path: newPrefix, kind: "object" }); | |
| entries.push(...enumerateEntries(v, opts, depth + 1, seen, newPrefix)); | |
| } else { | |
| // primitive – treat as a field for completion | |
| entries.push({ path: newPrefix, kind: "object" }); | |
| } | |
| } | |
| return entries; | |
| } | |
| function pathToExpr(rootAlias, pathArray) { | |
| let expr = rootAlias; | |
| for (const seg of pathArray) { | |
| if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(seg)) expr += "." + seg; | |
| else expr += `[${JSON.stringify(seg)}]`; | |
| } | |
| return expr; | |
| } | |
| function generateFlatStub(alias, rootValue, opts) { | |
| const entries = enumerateEntries(rootValue, opts, 0, new WeakSet(), []); | |
| const initSet = new Set(); | |
| const lines = []; | |
| lines.push(`/* Generated stub — for editor completions only */`); | |
| lines.push(`var ${alias} = {};`); | |
| function ensureParent(pathArr) { | |
| for (let i = 1; i <= pathArr.length - 1; i++) { | |
| const parentPath = pathArr.slice(0, i); | |
| const key = parentPath.join("\u0000"); | |
| if (initSet.has(key)) continue; | |
| initSet.add(key); | |
| const parentExpr = pathToExpr(alias, parentPath); | |
| lines.push(`${parentExpr} = ${parentExpr} || {};`); | |
| } | |
| } | |
| for (const e of entries) { | |
| ensureParent(e.path); | |
| const expr = pathToExpr(alias, e.path); | |
| if (e.kind === "function") { | |
| const args = Array.from( | |
| { length: e.arity }, | |
| (_, i) => `arg${i + 1}` | |
| ).join(", "); | |
| lines.push(`${expr} = function(${args}) {};`); | |
| } else { | |
| lines.push(`${expr} = ${expr} || {};`); | |
| } | |
| } | |
| lines.push(`module.exports = ${alias};`); | |
| lines.push(`export default ${alias};`); | |
| return lines.join("\n"); | |
| } | |
| function writeStub(outDir, pkgName, alias, contents) { | |
| // Support scoped packages by writing nested paths, e.g. out/@scope/pkg.js | |
| const file = path.join(outDir, ...pkgName.split("/")) + ".js"; | |
| ensureDir(path.dirname(file)); | |
| fs.writeFileSync(file, contents, "utf8"); | |
| return file; | |
| } | |
| async function main() { | |
| const args = parseArgs(process.argv); | |
| const pkgJsonPath = path.resolve(args.packageJson); | |
| if (!fs.existsSync(pkgJsonPath)) { | |
| console.error(`package.json not found at ${pkgJsonPath}`); | |
| process.exit(1); | |
| } | |
| const pkg = readJson(pkgJsonPath); | |
| const deps = Object.keys(pkg.dependencies || {}); | |
| const devDeps = Object.keys(pkg.devDependencies || {}); | |
| let all = args.includeDev ? deps.concat(devDeps) : deps; | |
| all = filterPkgs(all, args.filter); | |
| if (!all.length) { | |
| console.log("No packages selected. Use --filter to target specific deps."); | |
| return; | |
| } | |
| const outDir = path.resolve(args.output); | |
| ensureDir(outDir); | |
| const basedir = path.dirname(pkgJsonPath); | |
| const opts = { depth: args.depth, maxProps: args.maxProps }; | |
| const manifest = []; | |
| for (const name of all) { | |
| const alias = toIdentifier(name); | |
| try { | |
| const { mod, error } = await tryLoad(name, basedir); | |
| if (error || !mod) { | |
| console.warn( | |
| `Skipping ${name}: cannot load module (${ | |
| (error && error.message) || "unknown error" | |
| })` | |
| ); | |
| continue; | |
| } | |
| const root = | |
| mod && (typeof mod === "object" || typeof mod === "function") | |
| ? mod | |
| : {}; | |
| const stub = generateFlatStub(alias, root, opts); | |
| const file = writeStub(outDir, name, alias, stub); | |
| manifest.push({ package: name, alias, file }); | |
| console.log(`Stub written: ${file} (alias: ${alias})`); | |
| } catch (e) { | |
| console.warn(`Error stubbing ${name}: ${e.message}`); | |
| } | |
| } | |
| console.log( | |
| `\nDone. ${manifest.length} stub(s) generated.` | |
| ); | |
| } | |
| main().catch((err) => { | |
| console.error(err); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment