Skip to content

fileable

@johnhenry/fileable renders a JSX tree into real files and directories instead of a DOM. Three primitives — Dir, File, Rm — compose the same way any JSX does, but the output is a filesystem: static sites, scaffolded project skeletons, config bundles, build artifacts. src can pull in a local file, fetch a URL, cmd can shell out (opt-in), and content-hash caching (.fileable-lock.json) skips writing anything that hasn’t actually changed.

Previously published as fileable (2019-2022, last unscoped release 0.0.24 — the v1 iterator-protocol design). The v2 rewrite here was never released unscoped; adoption into @johnhenry/fileable and the version restart to 0.0.0 happened together — a new address and era, not a maturity signal.

Terminal window
npm install @johnhenry/fileable

Latest on npm: @johnhenry/[email protected].

/** @jsxImportSource @johnhenry/fileable */
import { Dir, File } from "@johnhenry/fileable";
import { render } from "@johnhenry/fileable";
const template = (
<Dir name="dist">
<File name="hello.txt">Hello, world!</File>
</Dir>
);
await render(template, { outDir: "." });
// -> dist/hello.txt

Or via the CLI, pointing at a compiled template module (.tsx/.jsx need pre-compiling — fileable ships no JSX/TS transform of its own):

Terminal window
fileable build template.js
  • Dir — a directory. from="glob" fills it from matched files, preserving their subdirectory structure (two different source dirs each containing an index.html do not collide — confirmed the hard way before this was fixed). as="archive" materializes the subtree as a .zip instead of loose files.
  • File — a file. Nesting a Dir/File inside another File folds it into the parent’s content instead of giving it its own path — the same rule handles “compose fragments into one file” and “compose files into a folder.” onConflict (replace/append/prepend/skip/error) controls what happens when Write is about to touch a path with content from outside the current build.
  • Rm — deletions, matched by glob, evaluated alongside everything else in the same tree (e.g. cleaning up *.draft.html on the same run that writes the real posts).

The bare lowercase <dir>/<file>/<rm> tags are reserved and throw if authored directly — always import { Dir, File, Rm } from "@johnhenry/fileable".

fileable build <template> [options]
fileable clean [dir] [options]
fileable eject <path> [options]

clean is build’s dual: it removes exactly what a previous build wrote (from .fileable-lock.json), not a guess at what “looks generated.” eject runs the whole idea backwards — point it at a real directory and it prints the fileable JSX source that would build it, deciding per file whether to inline the content or reference it via src="..." (text inlines, binary references, by default — forcing inline on binary content throws, since there’s no safe way to put raw bytes into source text). --dry-run works on every command that touches disk.

Things that aren’t obvious from the API surface alone:

  • Binary content is byte-exact, not assumed-text. src/cmd read raw bytes and decide text-vs-binary by a UTF-8 round-trip check, not by file extension — a real PNG through src comes back byte-for-byte identical, loose or inside a .zip.
  • A loose symlink can’t target content inside an as="archive" subtree. Archive-internal paths aren’t real filesystem paths — fileable throws instead of silently creating a symlink to nowhere.
  • as="archive" can’t nest, and you can’t escape back to loose from inside one. Both throw rather than silently doing the wrong thing.
  • The incremental cache checks the file is still actually there, not just that its hash matches. Delete a build output by hand and rebuild with caching on — it gets rewritten, not silently skipped forever.
  • render() never mutates the tree you pass it. Reusing the same built tree across two render() calls with different options (a normal SDK pattern) is safe — each call clones before touching anything.
  • servable mounts a fileable tree as static routes, in the same process and without writing to disk (<Group from={tree}>); fileable is servable’s optional peer dependency. The tutorial walks through both halves.
  • packfile reads <Dir encode="wbn"> output with fromArchive() and serves it with createRouter(); fileable depends on the wbn package, not on packfile. File names containing #, ? or % don’t survive the trip yet (fileable#20), and an empty <Dir> isn’t kept.
  • sitefile takes fileable output as a build input: the loose directory (COPY ./generated/ /) or an encode="zip" archive (IMPORT gen FROM ./site.zip AS tree). The encode="wbn" archive isn’t readable by Sitefile yet (sitefile#141). Neither package depends on the other. Sitefile’s repository also has an example plugin (examples/fileable/, on main) that renders a fileable tree inside a build with the in-memory toFiles(); cmd is refused there, since a plugin cannot start processes.

The table of every hand-off between fileable, packfile and Sitefile is on Sitefile’s Family page.

169 tests, zero runtime surprises left unguarded that a scratch-directory reproduction could catch — every fix above was verified by actually triggering the bug first, not just by reasoning about the code. CI covers Node 26 (the package’s engines.node floor) on both Linux and Windows (symlink-fallback behavior is genuinely platform-specific).

MIT

github.com/johnhenry/fileable