Loomreference

CLI

Complete reference for loom preview and loom build — every flag, its parsing rules, config precedence, and exit behavior.

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.

ArgumentDefaultRules
dir.Positional. Resolved against the current working directory. A missing directory exits 1 with loom: directory not found: <path>.
--port <n>5173Must 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]falseBoolean (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.

  1. --targets present, or loom.config.ts supplies targetsgallery mode. The generated page (sidebar + stage + #loom-root) mounts targets itself. No client entry is needed, and a project’s own index.html is overridden.

  2. Otherwise, if the project has an index.html → it is used as-is.

  3. Otherwise → a page is generated with a full-viewport #loom-root and a module script pointing at the first matching entry:

    src/main.client.tsx
    src/main.client.ts
    src/client/main.client.tsx
    src/client/main.client.ts
    src/main.tsx
    src/main.ts
    src/index.tsx
    src/client.tsx
  4. If none of those exist → exit 1 listing 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.

ArgumentDefaultRules
dir.Positional, resolved against the working directory.
--targets [glob]requiredSame parsing as preview. Absent → exit 1: loom: build requires --targets [glob] (the static gallery is target-driven).
--out <dir>dist-previewResolved against the working directory, not dir. Emptied before writing.
--base <path>./Vite’s public base path. The relative default keeps assets host-agnostic.
--no-assetsoffSkips 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-preview

loom.config.ts

Read only by preview, from <dir>/loom.config.ts. Imported through tsx; an import failure prints a warning and is otherwise ignored.

loom.config.ts
export default {
targets: "src/scenes", // string | string[]
port: 5204, // number
};

Precedence and validation:

  • CLI flags always win over the file.
  • targets must be a string, or a non-empty array of strings. Anything else makes the field invalid.
  • port is honored only when targets is valid. A config whose targets is missing or malformed is skipped in full, port included, with a hint on stderr.

Glob semantics

--targets normalization:

InputPattern 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:

Mount the gallery on a host dev server
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();
Emit the static gallery from a host build
await buildGallery({
root: "/path/to/my-ui",
targets: "src/scenes",
outDir: "dist/loom-preview", // --out
// base defaults to "./" — relative assets, hostable anywhere
});
ExportWhat 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.

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.