Skip to content

Repository files navigation

SplitCode — Split Large JS, TS, HTML or Python Files Into Dependency-Ordered Pieces

npm (coming soon) license: MIT

Split one giant app.js / app.ts / page / app.py into clean, load-ordered modules using pure static analysis — no bundler, no LLM, no config. SplitCode parses your code, finds real dependencies between top-level statements, groups coupled code together, and emits a drop-in bootstrap loader, so existing entry points keep working unchanged.

Keywords: javascript splitter, split js file, typescript splitter, split python file, refactor monolithic script, js code splitting without bundler, legacy code modularization, script dependency ordering.

Why SplitCode?

  • 📦 Break up monolithic scripts — turn a giant app.js into focused, reviewable files grouped by what actually depends on what.
  • 🔗 Correct load order, computed — references that run immediately enforce ordering; deferred callbacks don't (so a click handler won't manufacture false load-order cycles).
  • 🚀 Zero-integration loader — the generated app.js bootstrap pulls in every split file in order. Deploy the folder; change nothing else.
  • 🔍 Honest output — manifest.json reports hubs, duplicates, cycles, and parser mode, so you see exactly how cleanly your file decomposed.

Quick start

npm release pending — npx/-g commands below work once splitcode is published. Until then, use the from-source form (identical behavior).

# once published (no install needed):
npx splitcode app.js ./split-out

# or install globally:
npm install -g splitcode
splitcode app.js ./split-out

From source today (same thing — splitcode is just the bin alias for split-js.js):

npm install   # acorn + node-html-parser (+ optional typescript@5 for .ts)
node split-js.js app.js ./split-out

Deploy ./split-out and keep loading app.js from your pages — the generated loader pulls in the rest in dependency order. That's the whole migration.

For AI agents

Paste this into your project's CLAUDE.md / AGENTS.md so agents split before they read:

For files over ~100KB: run npx splitcode <file> ./split-out first, plan from ./split-out/manifest.json, edit per-file, never reorder order, test after every change.

A ready-made Claude Skill lives at skills/splitcode/SKILL.md — copy it into your project's .claude/skills/ (or global skills dir) and agents will invoke it automatically when files get large. Agent-readable docs: docs/llms.txt (also served at /llms.txt on the site).

How it works

  1. Parse the file with acorn into an AST (classic scripts; ES modules via fallback).
  2. Collect declarations — function/class/var/let/const, import bindings, plus global writes (foo = …, window.foo = …, Object.assign(window, {…})).
  3. Walk each statement with a scope-aware free-variable collector that distinguishes immediate references (run the instant the statement runs) from deferred ones (callbacks that fire later). Immediate includes IIFEs, fn.call/fn.apply, Promise executors, and a spec-backed allowlist (map/forEach/filter/every/some/find*/ reduce*/flatMap/sort, str.replace(re, fn), Array.from(it, fn), JSON.stringify(v, fn)). Block scoping (if/for/switch/bare {}) is respected; var still hoists; with bodies count every ref as live; window.foo reads link back to window.foo = … writes.
  4. Cluster tightly-coupled statements: connected-components first, then Louvain modularity refinement to cut weak bridges in oversized clusters. Over-shared "hub" globals (used everywhere) are excluded from grouping so they don't weld the file into one blob.
  5. Topologically order the files on immediate dependencies only. Function declarations are hoisted, so they move freely; anything that executes immediately (a bare call, const x = f()) keeps its relative position. Grouping ignores edge direction, so a cluster assignment can theoretically demand an impossible order (A before B before A) — the tool condenses such circular groups (Tarjan SCC) into single files first, so the emitted order is always satisfiable. Merges are logged and recorded (sccMerged); files get bigger, never wrong.
  6. Write the outputs (see below): cluster files, manifest.json, script-tags.html, and the app.js bootstrap loader.

Usage

splitcode <input.(js|ts|html|py)> <outDir> [--hub-ratio 0.12] [--min-chars 400] [--loader <name> | --no-loader] [--lang js|ts|html|py] [--target node|browser|auto] [--loader-mode classic|inline|require]
# from source: node split-js.js <same args>
Option Default Meaning
--hub-ratio 0.12 Names referenced by more than this fraction of statements are treated as shared app state, not a clustering signal. Floor: names used >6 times are always hubs; --no-hubs disables.
--min-chars 400 Clusters smaller than this merge into their neighbour, avoiding a pile of one-line files. Strictly validated (non-negative integer).
--loader app.js on Writes a bootstrap loader named app.js into outDir. Keep loading just that ONE file — it pulls in the split files in order. Load it with a plain <script src>, not async/defer. Rename with --loader bootstrap.js; a cluster that would collide gets suffixed (app-2.js).
--no-loader — Disables the loader; paste script-tags.html into your page instead (JS/TS/HTML; Python has no tags file).
--loader-mode classic|inline|require classic classic: loader pulls in parts at runtime via document.write (ESM inputs get type="module" tags). inline: loader is self-contained — all parts concatenated in order, no runtime injection, no extra requests. require (node, CJS js only): multi-file loader that vm-executes the parts in manifest order in one shared scope — node <loader> behaves like the original, exports included.
--target node|browser|auto auto Force the runtime target. auto detects it from the AST (require/module.exports/__dirname/process → node; document/navigator → browser; strings/comments never count; UMD typeof guards are ignored). A node target with the classic loader is refused with the one-line fix (classic needs a browser); under --check it stays advisory (browser-loader-on-node, exit 2). Detection result is recorded in the manifest (target, targetReason, loaderRuntime).
--no-louvain — Skip Louvain refinement; group by connected components only (faster, more predictable).
--no-hubs — Disable hub suppression entirely (--hub-ratio 0 can NOT do this — the >6 floor makes 0 the most aggressive setting).
--lang js|ts|html|py auto (extension) Force the frontend for extension-less or oddly-named inputs.
--check — Preflight only: scan for risk patterns, print warnings, write nothing. Exit 0 = clean, 2 = risky. outDir not needed.
--strict — Refuse to write when preflight warns (exit 2 instead of writing risky output).
--force — Allow overwriting colliding files and outputting into the input's own directory. The input file itself is still never deleted.
--dry-run — Plan everything, write nothing. outDir optional.
--max-bytes 33554432 Refuse inputs larger than this many bytes instead of risking OOM (0 = unlimited).
--timing — Print per-phase milliseconds at the end.
--quiet — Suppress info logs (warnings, errors and the final Wrote line still print).
--help / --version — Print help / version, exit 0.

Exit codes: 0 = success (warnings may be present unless --strict), 1 = error, 2 = risky (warnings under --check or --strict). Flags may appear before or after outDir. The output can replace the original file: keep loading just the loader and the host app behaves identically (verified syntax-only — smoke-test split apps before shipping).

Preflight (automatic, per file type)

Every run scans for constructs the splitter handles poorly and warns before writing anything. --check runs only the scan:

Lang ⚠ Warns • Notes
JS eval, indirect X.eval, new Function, setTimeout("…") strings, dynamic import(), importScripts (bare + X. member form), X.prototype.y =, Object.assign(X.prototype, …), dynamic global keys (window[x] =, non-literal Object.assign(window, …)), Object.defineProperty, module.exports / exports.x (cjs-exports — invisible to ordering; must stay in the last part), mixed import + require (esm-require-mix), top-level return in require mode (top-level-return), browser-loader-on-node, unparseable file getters/setters, top-level await, require(…), bare writes to undeclared names (e.g. loop inits), Node globals under a node target (node-globals), target-ambiguous (auto run could not tell node vs browser)
TS eval, indirect X.eval, new Function, setTimeout("…") strings, dynamic import(), importScripts (bare + member form), X.prototype.y =, Object.assign(X.prototype, …), Object.defineProperty (via compiler API), cjs-exports, browser-loader-on-node getters/setters, top-level await, require(…), node-globals, target-ambiguous
HTML <script> inside <template> (would be ACTIVATED), type="module" left in place
Python eval/exec strings, __import__, relative imports (BREAK in parts), from __future__ (must stay first) import *, __file__ (points at bootstrap)

Languages

Dispatch is by file extension (override with --lang js|ts|html|py). One shared backend clusters, orders and names — each language gets a frontend plus its own loader.

Input Frontend Output Loader
.js / .mjs / .cjs acorn AST, full scope analysis .js parts app.js — synchronous document.write bootstrap (browser), self-contained (inline) or vm-shared-scope bootstrap (require, node) — keep loading/running just it
.ts / .tsx typescript@5 API (v6+/native port has no JS AST API — pinned ^5.9) .ts parts (types kept) same mechanism, .ts file
.html pools every inline classic <script>; external src untouched rewritten page + .js parts loader tag inserted at the first inline block's position
.py python3 stdlib ast (requires python3 on PATH) .py parts app.py bootstrap — execs parts in order in shared globals, so module names behave exactly as one file

TypeScript specifics: type-position refs (: Foo, implements Bar) cluster but never order (erased at runtime); decorators/enum initializers/namespace bodies/static blocks are immediate; field initializers are deferred (construction time); <Foo /> tags count as refs. HTML specifics: type="module" / non-JS blocks (ld+json) and unparseable blocks are left in place (warned); an external src script between inline blocks can't be ordered against the pool (warned as externalInterleave). Python specifics: def-time evaluations (decorators, defaults, annotations, bases) are immediate; class bodies run at creation; methods can't see class-scope names (real Python scoping). __name__ == "__main__" blocks run exactly as before; caveat: __file__ inside a part points at the bootstrap. Behavioral check: original vs split stdout diffed — identical (modulo independent-print interleaving, see limitations).

Outputs (outDir)

  • Cluster files — named after their most-used declaration in kebab-case (auth-token.js), section-N.js fallback. Each carries a // Declares: … header comment.- Loader (app.js) — three shapes: classic resolves its own directory and injects the split files synchronously via document.write during parsing (browser; the only single-file mechanism with <script>-tag semantics — caveat: Chrome may block document.write-injected scripts on very slow (2G) connections, use script-tags.html then); inline concatenates every part in order into one self-contained file (works in browsers AND under Node); require (node CJS js inputs only) publishes the CommonJS wrapper bindings on globalThis and vm-executes each part in manifest order in one shared scope — node app.js and require('./app.js') behave like the original, module.exports included. Caveat: inside a part, __filename/__dirname point at the ENTRY loader's directory (where the parts live), not the original file.

  • manifest.json — machine-readable result:

    Field Meaning
    order Files with declares + statementCount, in load order
    loader Entry-point file name (null with --no-loader)

| loaderMode | classic (runtime injection), inline (self-contained) or require (node vm-shared-scope multi-file loader) | | target / targetReason | Detected/forced runtime target (node, browser, or unknown) and the AST reasons (require, module.exports, dunder-dir, process, browser-dom, window). js/ts only; absent otherwise | | loaderRuntime | Runtime the emitted loader executes under: node or browser (js/ts only) | | entryExports | Top-level CommonJS export names of the input (module.exports = {a, b} → ["a","b"]) — informational | | strict | Whether --strict was on for this run | | tool / toolVersion / schemaVersion | Producer identity (splitcode, semver, manifest schema 1) | | input | {file, bytes, statements, sha256} of the split source | | output | {statements, bytes} across parts (statements must equal input) | | hubNamesSuppressed | Shared-everywhere globals excluded from grouping | | hubThreshold | Effective use-count above which a name is a hub | | cycleFallback | Safety net only (condensation makes it unreachable); whether original-order fallback was used | | sccMerged | Circular-dependency groups merged into single files to guarantee order | | parserMode | script, or module if the ESM fallback parsed it | | duplicateDeclarations | Repeated top-level names (callers link to every same-name declaration) | | verified | Always "syntax-only" — what was (and wasn't) proven |

  • script-tags.html — paste-in alternative to the loader.

Hygiene (automatic)

  • Stale cleanup — reruns delete previous tool output (files listed in the prior manifest.json, plus the exact names about to be written — never extension globs) from outDir first, so orphaned files from a different cluster count can't linger. Unchanged files are hash-skipped, not rewritten. Foreign files are never touched; collisions refuse unless --force. The input file itself is never deleted or overwritten (same-dir output with a colliding loader name refuses — use a separate outDir).
  • CLI validation — bad --hub-ratio/--min-chars/--max-bytes values and unknown flags exit with an error instead of silently becoming NaN. Safety refusals (input overwrite, foreign-file collision) exit 1; --strict / --check report risk with exit 2.
  • Duplicate declarations — legal var/function redeclarations warn on console and in the manifest; callers link to every same-name declaration so load order stays safe (files get more coupled, never misordered).

Proven on a real 460KB app (671 top-level statements)

  • 671 statements in → 671 out. Nothing lost or duplicated (input checksum verified unchanged after the run).
  • Reassembled output passes node --check (syntax only — smoke-test split pages for behavior).
  • 18 files, largest 95 statements; no load-order cycles; zero ordering violations; 1 hub suppressed (toast); 1 duplicate declaration reported (showTabEditor — callers link to every same-name declaration).

Honest limitations

  • Side-effect order across files. Order is enforced only along dependency edges. Two top-level statements with side effects (prints, DOM writes) but no shared names may run in a different relative order after splitting — demonstrated by test: independent print lines swapped files. If exact interleaving matters, keep those statements coupled (shared name) or in one file.
  • An unknown receiver's callback defaults to deferred (arr.map(fn) is covered; a custom runNow(fn) is not) — the general case is undecidable by syntax analysis. Keep synchronously-coupled code together or verify order.
  • Dynamic global keys (window[x] = …) can't be tracked statically — detected and warned as dynamic-global-key, but readers stay unlinked.
  • Heavy shared mutable state genuinely merges files — the tool can't invent boundaries that don't exist. Check hubNamesSuppressed and cluster sizes.
  • Node servers with a full export object weld into one part. module.exports = { a, b, … } references every group, so the export statement joins their component and the split collapses toward a single file (plus whatever stays independent). That is honest output, not a bug — check entryExports and part sizes. --loader-mode inline is correct by construction regardless.
  • Node target detection is heuristic. A top-level require + export + __dirname/process file is unambiguous; a single-require script or a UMD bundle stays unknown (classic loader behavior, target-ambiguous note). typeof X guards are ignored by design — they prove nothing.
  • manifest.json carries "verified": "syntax-only" as a reminder of what was (and wasn't) proven.

Requirements

  • Node.js ≥ 16.
  • JS/HTML: acorn + node-html-parser (npm install).
  • TS: typescript@5 (optional dependency — installed by default, skippable with npm install --omit=optional; missing install fails with guidance).
  • Python: python3 on PATH (stdlib ast only — no pip packages).

License

MIT — do what you want, no warranty. Static analysis can miss dynamic edges; smoke-test split pages before shipping.

Author

unn-Known1 — ptelgm.yt@gmail.com (github.com/unn-Known1)

About

SplitCode — split large JS, TS, HTML or Python files into dependency-ordered pieces with pure static analysis. No bundler, drop-in loader.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages