loom — Roblox UI preview
Usage: loom preview [dir] [--port <n>] [--host] [--targets [glob]] loom build [dir] --targets [glob] [--out <dir>] [--base <path>] [--no-assets]Any other first argument (including none) prints this usage text and exits 0.
loom preview
Starts a Vite dev server with loomPreview() pre-applied and configFile: false — the project’s own
vite.config.ts, if it has one, is deliberately ignored.
| Argument | Default | Rules |
|---|---|---|
dir | . | Positional. Resolved against the current working directory. A missing directory exits 1 with loom: directory not found: <path>. |
--port <n> | 5173 | Must be all digits, else exits 1 with loom: --port requires a numeric value. Left unset when absent so loom.config.ts can supply it. |
--host [addr] | false | Boolean (binds all interfaces) unless followed by a value not starting with -, which is used as the bind address. |
--targets [glob] | — | Presence switches to gallery mode. A following value not starting with - is used as the glob; otherwise the default **/*.loom.tsx applies. |
Mode selection
The CLI decides between gallery and single-entry mode and hands the result to loomPreview() as its
targets option; the plugin does the serving either way, so
loomPreview({ targets }) in your own
vite.config.ts behaves identically.
-
--targetspresent, orloom.config.tssuppliestargets→ gallery mode. The generated page (sidebar + stage +#loom-root) mounts targets itself. No client entry is needed, and a project’s ownindex.htmlis overridden. -
Otherwise, if the project has an
index.html→ it is used as-is. -
Otherwise → a page is generated with a full-viewport
#loom-rootand a module script pointing at the first matching entry:src/main.client.tsxsrc/main.client.tssrc/client/main.client.tsxsrc/client/main.client.tssrc/main.tsxsrc/main.tssrc/index.tsxsrc/client.tsx -
If none of those exist → exit
1listing the candidates. (The CLI pre-flights the same lookup the plugin would do, so this is a clean exit rather than a server that fails on first request.)
File-system access
server.fs.allow is the union of: the project directory, the nearest ancestor containing a
pnpm-workspace.yaml (if any), and the directory containing the installed @loom-dev/preview
sources. .git alone is not treated as a workspace marker — it would over-widen access to, say, a
home-directory repository.
loom build
Runs vite build programmatically over the same gallery and writes a self-contained, client-only SPA.
It is loomPreview({ targets }) under vite build with configFile: false and nothing else, so
vite build in a project whose vite.config.ts passes targets emits the same output.
| Argument | Default | Rules |
|---|---|---|
dir | . | Positional, resolved against the working directory. |
--targets [glob] | required | Same parsing as preview. Absent → exit 1: loom: build requires --targets [glob] (the static gallery is target-driven). |
--out <dir> | dist-preview | Resolved against the working directory, not dir. Emptied before writing. |
--base <path> | ./ | Vite’s public base path. The relative default keeps assets host-agnostic. |
--no-assets | off | Skips the rbxassetid:// bake, keeping the build off the network — see static builds. Boolean; takes no value. |
build does not read loom.config.ts. If no file matches the glob it throws
no targets matched <patterns> under <root> — nothing to build and exits non-zero.
On success it prints the resolved output directory:
loom build → /Users/you/code/my-ui/dist-previewloom.config.ts
Read only by preview, from <dir>/loom.config.ts. Imported through tsx; an import failure prints
a warning and is otherwise ignored.
export default { targets: "src/scenes", // string | string[] port: 5204, // number};Precedence and validation:
- CLI flags always win over the file.
targetsmust be a string, or a non-empty array of strings. Anything else makes the field invalid.portis honored only whentargetsis valid. A config whosetargetsis missing or malformed is skipped in full, port included, with a hint on stderr.
Glob semantics
--targets normalization:
| Input | Pattern used |
|---|---|
| flag with no value | **/*.loom.tsx |
. or an empty string | **/*.loom.tsx |
a value containing * | used verbatim |
| any other value (a directory) | <value>/**/*.loom.tsx |
Leading ./ and trailing slashes are stripped first. The matcher supports exactly three
constructs — **/ (any depth including none), ** (anything), * (anything but /) — and treats
every other character literally. There is no brace expansion, no ?, no negation, no extglob.
Discovery walks the project directory recursively, skipping node_modules and any entry whose name
starts with ., and returns sorted POSIX-style relative paths. Unreadable directories are skipped
rather than fatal.
Live target discovery
Under the dev server, adding or deleting a *.loom.tsx file invalidates the virtual target module
and triggers a full page reload — the sidebar is rebuilt from the import map, so a reload is the
simplest correct signal. The URL hash survives it, keeping the selected target selected. Editing a
target’s contents is ordinary HMR.
loom-dev/embed — the programmatic API
Both commands own the process: preview binds a port, build owns an output directory. A host
application — a docs site, a design-system portal — owns both already. loom-dev/embed exposes the
same two pipelines as functions so the host can mount them:
import { createGalleryServer, buildGallery } from "loom-dev/embed";
const gallery = await createGalleryServer({ root: "/path/to/my-ui", // project the targets live in targets: "src/scenes", // same semantics as --targets base: "/loom-preview/", // public path it answers under});
hostServer.middlewares.use(gallery.middleware);// …and on shutdown:await gallery.close();await buildGallery({ root: "/path/to/my-ui", targets: "src/scenes", outDir: "dist/loom-preview", // --out // base defaults to "./" — relative assets, hostable anywhere});| Export | What it does |
|---|---|
createGalleryServer(options) | Gallery Vite server in middleware mode; resolves to { base, middleware, vite, close() } |
buildGallery(options) | Static gallery build; resolves to the resolved output directory. Takes assets: false to skip the rbxassetid:// bake, as withLoomGallery does |
findGalleryTargets(root, targets?) | The relative target paths that would be served — for skipping cleanly when a checkout is absent |
isGalleryRequest(url, base) | Whether a URL belongs to a gallery mounted at base |
normalizeGalleryBase(base?) | Forces the leading and trailing slash Vite’s base wants |
DEFAULT_GALLERY_BASE | "/loom-preview/" |
createGalleryServer also takes hmrPort. Middleware mode has no HTTP server of its own to upgrade,
so Vite always puts HMR on a standalone port — and its default (24678) is the first port every other
Vite dev server grabs. A collision is not fatal but it is quiet: Vite logs Port 24678 is already in use and previews then never hot-reload. Left unset, loom picks a free port per server; pass a number
to pin one, or false to run without HMR.
createGalleryServer starts a separate Vite instance and the host only forwards HTTP to it. That
is deliberate: the loom plugin rewrites react and @rbxts/* for the whole config it lives in (see
Vite integration), so dropping it into a host app’s config would
hijack the host’s own React. The one thing that is not proxied is Vite’s HMR WebSocket — middleware
mode always gives it a port of its own (see hmrPort above).
middleware answers only under base — including the bare /loom-preview, which it redirects to
the trailing-slash form — and calls next() for everything else, so it is safe to register ahead of
the host’s own routes. Everything else is identical to the CLI: the same target discovery, the same
?target=/?chrome=/?theme= URL contract,
the same per-target error containment.