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, so the project’s
own vite.config.ts is 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 picks gallery or single-entry mode and hands the result to loomPreview() as targets. The
plugin serves either way, so
loomPreview({ targets }) in your own config
behaves identically.
-
--targetspresent, orloom.config.tssuppliestargets→ gallery mode. The generated page mounts targets itself. No client entry is needed, and a project’s ownindex.htmlis overridden. -
Otherwise, a project
index.htmlis 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 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, and the directory holding the installed @loom-dev/preview sources. .git
alone is not a workspace marker — it would over-widen access to 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, so a project
whose own config 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, and exits non-zero with
no targets matched … — nothing to build when the glob matches nothing.
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. It is imported through tsx, and 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.portis honoured only whentargetsis valid. A config with a missing or malformedtargetsis 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 three constructs: **/
for any depth including none, ** for anything, and * for anything but /. Every other character
is literal.
Discovery walks the project recursively, skipping node_modules and dot-entries, 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, since the sidebar is rebuilt from the import map. The URL hash
survives it, and 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 owns both already, so loom-dev/embed exposes the same two pipelines as functions:
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. Its default, 24678, is the first port every
other Vite dev server grabs. A collision is quiet rather than fatal: Vite logs it, and previews
never hot-reload. Left unset, loom picks a free port per server.
createGalleryServer starts a separate Vite instance, and the host only forwards HTTP to it. The loom plugin rewrites react and @rbxts/* for the whole config it lives in, and would otherwise hijack the host’s own React. The one thing not proxied is Vite’s HMR WebSocket, which always gets a port of its own.
middleware answers only under base, redirecting the bare /loom-preview to the trailing-slash
form. It calls next() for everything else, so it is safe ahead of the host’s own routes.
Everything else matches the CLI: the same target discovery, the same
URL contract, the same per-target
error containment.