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/fileableand the version restart to 0.0.0 happened together — a new address and era, not a maturity signal.
Install
Section titled “Install”npm install @johnhenry/fileable<!-- In <head>, before any <script type="module"> or modulepreload. --><script type="importmap"> { "imports": { "@johnhenry/fileable/browser": "https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/dist/src/browser/index.js", "marked": "https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.esm.js", "node-html-parser": "https://cdn.jsdelivr.net/npm/[email protected]/dist/index.mjs" } }</script><script type="module"> import * as fileableBrowser from "@johnhenry/fileable/browser";</script>Quick example
Section titled “Quick example”/** @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.txtOr via the CLI, pointing at a compiled template module (.tsx/.jsx need
pre-compiling — fileable ships no JSX/TS transform of its own):
fileable build template.jsThe three primitives
Section titled “The three primitives”Dir— a directory.from="glob"fills it from matched files, preserving their subdirectory structure (two different source dirs each containing anindex.htmldo not collide — confirmed the hard way before this was fixed).as="archive"materializes the subtree as a.zipinstead of loose files.File— a file. Nesting aDir/Fileinside anotherFilefolds 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.htmlon 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.
Gotchas
Section titled “Gotchas”Things that aren’t obvious from the API surface alone:
- Binary content is byte-exact, not assumed-text.
src/cmdread raw bytes and decide text-vs-binary by a UTF-8 round-trip check, not by file extension — a real PNG throughsrccomes back byte-for-byte identical, loose or inside a.zip. - A loose
symlinkcan’t target content inside anas="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 tworender()calls with different options (a normal SDK pattern) is safe — each call clones before touching anything.
Family
Section titled “Family”- 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 withfromArchive()and serves it withcreateRouter(); fileable depends on thewbnpackage, 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 anencode="zip"archive (IMPORT gen FROM ./site.zip AS tree). Theencode="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-memorytoFiles();cmdis 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.
Status
Section titled “Status”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).
License
Section titled “License”MIT