# CLI

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

Source: https://docs.astra-void.xyz/loom/reference/cli/

```text
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 })`](https://docs.astra-void.xyz/loom/reference/vite-plugin.md#the-generated-page) in your own
`vite.config.ts` behaves identically.

1. `--targets` present, or `loom.config.ts` supplies `targets` → **gallery 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:

   ```text
   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.

| 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](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#rbxassetid-in-a-static-build). 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:

```text
  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.

```ts title="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:

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

```ts title="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();
```

```ts title="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
});
```

| 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.

> **It runs its own Vite**
>
> `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](https://docs.astra-void.xyz/loom/guides/vite-integration.md)), 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](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#the-url-contract),
the same per-target error containment.
