gantry-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/styles.css ADDED
@@ -0,0 +1,309 @@
1
+ /* Gantry chrome + Tea built-in styles. Theme everything from the app's
2
+ root index.css by overriding these variables - the components read
3
+ them live, so a retheme is just new values, no component changes. */
4
+
5
+ :root {
6
+ --gantry-bg: #101012;
7
+ --gantry-fg: #e8e8ea;
8
+ --gantry-fg-dim: #9a9aa0;
9
+ --gantry-accent: #6ea8fe;
10
+ --gantry-border: #2a2a2e;
11
+ --gantry-titlebar-h: 40px;
12
+ --gantry-titlebar-bg: transparent;
13
+ --gantry-titlebar-fg: var(--gantry-fg);
14
+ --gantry-btn-hover: rgba(255, 255, 255, 0.08);
15
+ --gantry-close-hover: #c42b1c;
16
+ --gantry-control-bg: #1a1a1e;
17
+ --gantry-control-border: #34343a;
18
+ --gantry-radius: 6px;
19
+ --gantry-font:
20
+ "Segoe UI", system-ui, -apple-system, sans-serif;
21
+ }
22
+
23
+ /* ---- window chrome ---- */
24
+
25
+ .gantry-titlebar {
26
+ position: relative;
27
+ display: flex;
28
+ align-items: center;
29
+ flex: none;
30
+ background: var(--gantry-titlebar-bg);
31
+ color: var(--gantry-titlebar-fg);
32
+ font-family: var(--gantry-font);
33
+ user-select: none;
34
+ }
35
+
36
+ .gantry-dragstrip {
37
+ position: absolute;
38
+ top: 0;
39
+ z-index: 30;
40
+ }
41
+
42
+ /* The custom slots sit flush against the window edges; space them
43
+ with your own padding/margins if you want inset. */
44
+ .gantry-titlebar-left {
45
+ display: flex;
46
+ align-items: stretch;
47
+ height: 100%;
48
+ position: relative;
49
+ z-index: 31;
50
+ }
51
+
52
+ .gantry-titlebar-right {
53
+ display: flex;
54
+ align-items: stretch;
55
+ height: 100%;
56
+ }
57
+
58
+ /* Titlebar buttons for YOUR controls (back/forward, menus, actions) -
59
+ styled independently of the window buttons via their own variables. */
60
+ .gantry-tbbtn {
61
+ display: inline-flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ min-width: var(--gantry-tbbtn-w, 40px);
65
+ height: 100%;
66
+ padding: 0;
67
+ border: none;
68
+ border-radius: 0;
69
+ outline: none;
70
+ background: var(--gantry-tbbtn-bg, transparent);
71
+ color: var(--gantry-tbbtn-fg, var(--gantry-titlebar-fg));
72
+ cursor: default;
73
+ }
74
+
75
+ .gantry-tbbtn:hover {
76
+ background: var(--gantry-tbbtn-hover, var(--gantry-btn-hover));
77
+ }
78
+
79
+ .gantry-titlebar-title {
80
+ position: absolute;
81
+ left: 0;
82
+ right: 0;
83
+ text-align: center;
84
+ font-size: 13px;
85
+ color: var(--gantry-fg-dim);
86
+ pointer-events: none; /* stays part of the drag band */
87
+ }
88
+
89
+ /* Left/right-aligned title text (inside the flex sections). */
90
+ .gantry-titlebar-text {
91
+ display: flex;
92
+ align-items: center;
93
+ font-size: 13px;
94
+ color: var(--gantry-fg-dim);
95
+ padding: 0 8px;
96
+ pointer-events: none;
97
+ white-space: nowrap;
98
+ }
99
+
100
+ .gantry-titlebar-end {
101
+ display: flex;
102
+ align-items: stretch;
103
+ height: 100%;
104
+ margin-left: auto;
105
+ position: relative;
106
+ z-index: 31;
107
+ }
108
+
109
+ .gantry-winbtn {
110
+ display: inline-flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ width: 46px;
114
+ height: 100%; /* follows the bar's height, thin or tall */
115
+ padding: 0;
116
+ border: none;
117
+ border-radius: 0;
118
+ outline: none;
119
+ background: transparent;
120
+ color: var(--gantry-titlebar-fg);
121
+ cursor: default;
122
+ }
123
+
124
+ .gantry-winbtn:hover {
125
+ background: var(--gantry-btn-hover);
126
+ }
127
+
128
+ .gantry-winbtn-close:hover {
129
+ background: var(--gantry-close-hover);
130
+ color: #fff;
131
+ }
132
+
133
+ /* ---- Tea built-ins ---- */
134
+
135
+ .gantry-tea-column {
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: 8px;
139
+ min-height: 0;
140
+ }
141
+
142
+ .gantry-tea-row {
143
+ display: flex;
144
+ flex-direction: row;
145
+ align-items: center;
146
+ gap: 8px;
147
+ min-width: 0;
148
+ }
149
+
150
+ .gantry-tea-text {
151
+ font-family: var(--gantry-font);
152
+ color: var(--gantry-fg);
153
+ font-size: 14px;
154
+ }
155
+
156
+ .gantry-tea-heading {
157
+ font-family: var(--gantry-font);
158
+ color: var(--gantry-fg);
159
+ font-size: 18px;
160
+ font-weight: 600;
161
+ margin: 0;
162
+ }
163
+
164
+ .gantry-tea-button {
165
+ font-family: var(--gantry-font);
166
+ font-size: 13px;
167
+ color: var(--gantry-fg);
168
+ background: var(--gantry-control-bg);
169
+ border: 1px solid var(--gantry-control-border);
170
+ border-radius: var(--gantry-radius);
171
+ padding: 6px 14px;
172
+ }
173
+
174
+ .gantry-tea-button:hover {
175
+ border-color: var(--gantry-accent);
176
+ }
177
+
178
+ .gantry-tea-button:active {
179
+ transform: translateY(1px);
180
+ }
181
+
182
+ .gantry-tea-input,
183
+ .gantry-tea-select {
184
+ font-family: var(--gantry-font);
185
+ font-size: 13px;
186
+ color: var(--gantry-fg);
187
+ background: var(--gantry-control-bg);
188
+ border: 1px solid var(--gantry-control-border);
189
+ border-radius: var(--gantry-radius);
190
+ padding: 6px 10px;
191
+ outline: none;
192
+ }
193
+
194
+ .gantry-tea-input:focus,
195
+ .gantry-tea-select:focus {
196
+ border-color: var(--gantry-accent);
197
+ }
198
+
199
+ .gantry-tea-checkbox {
200
+ display: inline-flex;
201
+ align-items: center;
202
+ gap: 8px;
203
+ font-family: var(--gantry-font);
204
+ font-size: 13px;
205
+ color: var(--gantry-fg);
206
+ }
207
+
208
+ .gantry-tea-divider {
209
+ border: none;
210
+ border-top: 1px solid var(--gantry-border);
211
+ margin: 4px 0;
212
+ width: 100%;
213
+ }
214
+
215
+ .gantry-tea-spacer {
216
+ flex: 1;
217
+ }
218
+
219
+ .gantry-tea-progress {
220
+ height: 6px;
221
+ border-radius: 3px;
222
+ background: var(--gantry-control-bg);
223
+ overflow: hidden;
224
+ min-width: 80px;
225
+ flex: 1;
226
+ }
227
+
228
+ .gantry-tea-progress-fill {
229
+ height: 100%;
230
+ background: var(--gantry-accent);
231
+ }
232
+
233
+ .gantry-tea-unknown {
234
+ font-family: var(--gantry-font);
235
+ font-size: 12px;
236
+ color: #ff8080;
237
+ border: 1px dashed #ff8080;
238
+ border-radius: var(--gantry-radius);
239
+ padding: 4px 8px;
240
+ }
241
+
242
+ /* ---- themed defaults for plain elements ----
243
+ Pages write ordinary <input>/<button>/<select> and get the app look
244
+ without any per-page css; the Tea built-ins carry the same values. */
245
+
246
+ .gantry-app input[type="text"],
247
+ .gantry-app input[type="password"],
248
+ .gantry-app input[type="number"],
249
+ .gantry-app input[type="search"],
250
+ .gantry-app input:not([type]),
251
+ .gantry-app textarea,
252
+ .gantry-app select {
253
+ font-family: var(--gantry-font);
254
+ font-size: 13px;
255
+ color: var(--gantry-fg);
256
+ background: var(--gantry-control-bg);
257
+ border: 1px solid var(--gantry-control-border);
258
+ border-radius: var(--gantry-radius);
259
+ padding: 6px 10px;
260
+ outline: none;
261
+ }
262
+
263
+ .gantry-app input:focus,
264
+ .gantry-app textarea:focus,
265
+ .gantry-app select:focus {
266
+ border-color: var(--gantry-accent);
267
+ }
268
+
269
+ .gantry-app button:not(.gantry-winbtn):not(.gantry-tbbtn) {
270
+ font-family: var(--gantry-font);
271
+ font-size: 13px;
272
+ color: var(--gantry-fg);
273
+ background: var(--gantry-control-bg);
274
+ border: 1px solid var(--gantry-control-border);
275
+ border-radius: var(--gantry-radius);
276
+ padding: 6px 14px;
277
+ }
278
+
279
+ .gantry-app button:not(.gantry-winbtn):not(.gantry-tbbtn):hover {
280
+ border-color: var(--gantry-accent);
281
+ }
282
+
283
+ .gantry-app input[type="checkbox"] {
284
+ accent-color: var(--gantry-accent);
285
+ }
286
+
287
+ /* Link renders anchors without href (no status bubble); keep the
288
+ pointer cursor they would otherwise lose. */
289
+ .gantry-app a[role="link"] {
290
+ cursor: pointer;
291
+ }
292
+
293
+ /* ---- app scaffold ---- */
294
+
295
+ .gantry-app {
296
+ display: flex;
297
+ flex-direction: column;
298
+ height: 100vh;
299
+ background: var(--gantry-bg);
300
+ color: var(--gantry-fg);
301
+ font-family: var(--gantry-font);
302
+ overflow: hidden;
303
+ }
304
+
305
+ .gantry-page {
306
+ flex: 1;
307
+ min-height: 0;
308
+ overflow: auto;
309
+ }
@@ -0,0 +1,195 @@
1
+ import {
2
+ createElement,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type FC,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { connect, onRender, sendTeaEvent, type WireNode } from "../socket";
10
+
11
+ /** Props every Tea-rendered component receives. */
12
+ export interface TeaComponentProps {
13
+ node: WireNode;
14
+ /** emit fires one of the node's Go handlers by event name. */
15
+ emit: (event: string, payload?: unknown) => void;
16
+ children?: ReactNode;
17
+ }
18
+
19
+ export type ComponentRegistry = Record<string, FC<TeaComponentProps>>;
20
+
21
+ // extraComponents lets createApp add explicitly-registered components on
22
+ // top of the paired components/ folders.
23
+ let appRegistry: ComponentRegistry = {};
24
+
25
+ export function setRegistry(reg: ComponentRegistry): void {
26
+ appRegistry = reg;
27
+ }
28
+
29
+ function emitFor(node: WireNode) {
30
+ return (event: string, payload?: unknown) => {
31
+ const id = node.handlers?.[event];
32
+ if (id) sendTeaEvent(id, payload);
33
+ };
34
+ }
35
+
36
+ function styleHints(props: Record<string, unknown> | undefined): {
37
+ style: Record<string, string | number>;
38
+ className: string;
39
+ } {
40
+ const style: Record<string, string | number> = {};
41
+ let className = "";
42
+ if (props) {
43
+ if (typeof props.gap === "number") style.gap = props.gap;
44
+ if (typeof props.pad === "number") style.padding = props.pad;
45
+ if (props.grow === true) style.flexGrow = 1;
46
+ if (typeof props.class === "string") className = props.class;
47
+ }
48
+ return { style, className };
49
+ }
50
+
51
+ function renderChildren(children: WireNode[] | undefined): ReactNode[] {
52
+ return (children ?? []).map((c, i) => <NodeView key={c.key ?? i} node={c} />);
53
+ }
54
+
55
+ // TeaInput is semi-controlled: keystrokes echo locally at once and
56
+ // stream to Go; the Go value only overrides the field when it differs
57
+ // from what we last sent (a real remote change, not our own echo
58
+ // arriving late). A pure controlled input over a websocket round trip
59
+ // drops and jumbles fast typing.
60
+ function TeaInput({ node }: { node: WireNode }) {
61
+ const server = String(node.props?.value ?? "");
62
+ const [local, setLocal] = useState(server);
63
+ const lastSent = useRef<string | null>(null);
64
+ const emit = emitFor(node);
65
+
66
+ useEffect(() => {
67
+ if (lastSent.current === null || server !== lastSent.current) {
68
+ setLocal(server);
69
+ lastSent.current = null;
70
+ } else if (server === lastSent.current) {
71
+ lastSent.current = null;
72
+ }
73
+ }, [server]);
74
+
75
+ return (
76
+ <input
77
+ className="gantry-tea-input"
78
+ type="text"
79
+ value={local}
80
+ placeholder={typeof node.props?.placeholder === "string" ? node.props.placeholder : undefined}
81
+ onChange={(e) => {
82
+ setLocal(e.target.value);
83
+ lastSent.current = e.target.value;
84
+ emit("change", e.target.value);
85
+ }}
86
+ />
87
+ );
88
+ }
89
+
90
+ function NodeView({ node }: { node: WireNode }) {
91
+ const { style, className } = styleHints(node.props);
92
+ const cls = (base: string) => base + (className ? " " + className : "");
93
+ const emit = emitFor(node);
94
+
95
+ switch (node.type) {
96
+ case "column":
97
+ return (
98
+ <div className={cls("gantry-tea-column")} style={style}>
99
+ {renderChildren(node.children)}
100
+ </div>
101
+ );
102
+ case "row":
103
+ return (
104
+ <div className={cls("gantry-tea-row")} style={style}>
105
+ {renderChildren(node.children)}
106
+ </div>
107
+ );
108
+ case "text":
109
+ return (
110
+ <span className={cls("gantry-tea-text")} style={style}>
111
+ {String(node.props?.text ?? "")}
112
+ </span>
113
+ );
114
+ case "heading":
115
+ return (
116
+ <h2 className={cls("gantry-tea-heading")} style={style}>
117
+ {String(node.props?.text ?? "")}
118
+ </h2>
119
+ );
120
+ case "button":
121
+ return (
122
+ <button
123
+ type="button"
124
+ className={cls("gantry-tea-button")}
125
+ style={style}
126
+ onClick={() => emit("click")}
127
+ >
128
+ {String(node.props?.label ?? "")}
129
+ </button>
130
+ );
131
+ case "input":
132
+ return <TeaInput node={node} />;
133
+ case "checkbox":
134
+ return (
135
+ <label className={cls("gantry-tea-checkbox")} style={style}>
136
+ <input
137
+ type="checkbox"
138
+ checked={node.props?.checked === true}
139
+ onChange={(e) => emit("change", e.target.checked)}
140
+ />
141
+ <span>{String(node.props?.label ?? "")}</span>
142
+ </label>
143
+ );
144
+ case "select":
145
+ return (
146
+ <select
147
+ className={cls("gantry-tea-select")}
148
+ style={style}
149
+ value={String(node.props?.value ?? "")}
150
+ onChange={(e) => emit("change", e.target.value)}
151
+ >
152
+ {((node.props?.options as string[]) ?? []).map((o) => (
153
+ <option key={o} value={o}>
154
+ {o}
155
+ </option>
156
+ ))}
157
+ </select>
158
+ );
159
+ case "divider":
160
+ return <hr className="gantry-tea-divider" />;
161
+ case "spacer":
162
+ return <div className="gantry-tea-spacer" />;
163
+ case "progress": {
164
+ const v = Math.min(1, Math.max(0, Number(node.props?.value ?? 0)));
165
+ return (
166
+ <div className={cls("gantry-tea-progress")} style={style}>
167
+ <div className="gantry-tea-progress-fill" style={{ width: (v * 100).toFixed(1) + "%" }} />
168
+ </div>
169
+ );
170
+ }
171
+ default: {
172
+ const Custom = appRegistry[node.type];
173
+ if (Custom) {
174
+ return createElement(Custom, { node, emit }, renderChildren(node.children));
175
+ }
176
+ return <div className="gantry-tea-unknown">[unknown component: {node.type}]</div>;
177
+ }
178
+ }
179
+ }
180
+
181
+ /**
182
+ * TeaView renders the Go-driven tree for the current page. Put one in
183
+ * any page whose .go half has a Model; pages without a Model simply do
184
+ * not use it.
185
+ */
186
+ export function TeaView() {
187
+ const [tree, setTree] = useState<WireNode | null>(null);
188
+ useEffect(() => {
189
+ connect();
190
+ onRender(setTree);
191
+ return () => onRender(null);
192
+ }, []);
193
+ if (!tree) return null;
194
+ return <NodeView node={tree} />;
195
+ }
@@ -0,0 +1,5 @@
1
+ export { TeaView, setRegistry } from "./Runtime";
2
+ export type { TeaComponentProps, ComponentRegistry } from "./Runtime";
3
+ export { usePaired } from "../paired";
4
+ export type { Paired } from "../paired";
5
+ export type { WireNode } from "../socket";
@@ -0,0 +1,203 @@
1
+ // The Gantry Vite plugin. Plain JavaScript on purpose: vite.config is
2
+ // executed by Node, which cannot load TypeScript out of node_modules.
3
+ //
4
+ // What it does:
5
+ // - scans <appRoot>/pages/** and <appRoot>/components/** for paired
6
+ // <name>/<name>.tsx files and generates the virtual:gantry-app
7
+ // module (route table + component registry, keyed by folder path)
8
+ // - auto-imports <appRoot>/index.css and every colocated same-name
9
+ // .css (root css first, so per-pair css can override variables)
10
+ // - injects the pairing key into bare usePaired() calls, derived from
11
+ // the file's folder path, so the tsx never repeats it
12
+ // - proxies /api and /gantry/ws to the Go server during gantry dev
13
+ // - watches pages/ and components/ so adding or removing a pair
14
+ // regenerates the app without restarting the dev server
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ const VIRTUAL_ID = "virtual:gantry-app";
20
+ const RESOLVED_ID = "\0" + VIRTUAL_ID;
21
+
22
+ /**
23
+ * @param {{ appRoot?: string, goPort?: number }} [opts]
24
+ * @returns {import("vite").Plugin}
25
+ */
26
+ export function gantry(opts = {}) {
27
+ /** @type {string} */
28
+ let appRoot = "";
29
+ let goPort = opts.goPort ?? 0;
30
+
31
+ function scanKind(kind) {
32
+ const dir = path.join(appRoot, kind);
33
+ const out = [];
34
+ if (!fs.existsSync(dir)) return out;
35
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
36
+ if (!entry.isDirectory()) continue;
37
+ const name = entry.name;
38
+ const tsx = path.join(dir, name, name + ".tsx");
39
+ if (!fs.existsSync(tsx)) continue;
40
+ const css = path.join(dir, name, name + ".css");
41
+ out.push({
42
+ key: kind + "/" + name,
43
+ name,
44
+ tsx: tsx.split(path.sep).join("/"),
45
+ css: fs.existsSync(css) ? css.split(path.sep).join("/") : null,
46
+ });
47
+ }
48
+ out.sort((a, b) => a.key.localeCompare(b.key));
49
+ return out;
50
+ }
51
+
52
+ function generate() {
53
+ const pages = scanKind("pages");
54
+ const components = scanKind("components");
55
+ const lines = [];
56
+
57
+ const layouts = scanKind("layouts");
58
+
59
+ const rootCss = path.join(appRoot, "index.css");
60
+ if (fs.existsSync(rootCss)) {
61
+ lines.push(`import ${JSON.stringify(rootCss.split(path.sep).join("/"))};`);
62
+ }
63
+ for (const item of [...layouts, ...pages, ...components]) {
64
+ if (item.css) lines.push(`import ${JSON.stringify(item.css)};`);
65
+ }
66
+
67
+ layouts.forEach((l, i) => {
68
+ lines.push(`import * as l${i} from ${JSON.stringify(l.tsx)};`);
69
+ });
70
+
71
+ // Optional root app.tsx: default-exports CreateAppOptions so apps
72
+ // customize createApp (TitleBar slots, title placement, extra
73
+ // components) without owning the synthesized entry file.
74
+ const appTsx = path.join(appRoot, "app.tsx");
75
+ if (fs.existsSync(appTsx)) {
76
+ lines.push(`import * as appMod from ${JSON.stringify(appTsx.split(path.sep).join("/"))};`);
77
+ lines.push(`export const appConfig = appMod;`);
78
+ } else {
79
+ lines.push(`export const appConfig = null;`);
80
+ }
81
+
82
+ pages.forEach((p, i) => {
83
+ lines.push(`import * as p${i} from ${JSON.stringify(p.tsx)};`);
84
+ });
85
+ components.forEach((c, i) => {
86
+ lines.push(`import * as c${i} from ${JSON.stringify(c.tsx)};`);
87
+ });
88
+
89
+ const pageEntries = pages.map((p, i) => {
90
+ // The derived route only; an optional "export const route" on the
91
+ // page module overrides it at runtime (createApp checks mod.route
92
+ // there - referencing it here would make rollup warn on every
93
+ // page that does not export it).
94
+ const route = p.name === "index" ? "/" : "/" + p.name;
95
+ return `{ key: ${JSON.stringify(p.key)}, route: ${JSON.stringify(route)}, mod: p${i} }`;
96
+ });
97
+ lines.push(`export const pages = [${pageEntries.join(", ")}];`);
98
+
99
+ const compEntries = components.map((c, i) => `${JSON.stringify(c.key)}: c${i}`);
100
+ lines.push(`export const components = { ${compEntries.join(", ")} };`);
101
+
102
+ // Layouts are keyed by their short name: layouts/main -> "main",
103
+ // which is what pages reference in "export const layout".
104
+ const layoutEntries = layouts.map((l, i) => `${JSON.stringify(l.name)}: l${i}`);
105
+ lines.push(`export const layouts = { ${layoutEntries.join(", ")} };`);
106
+ lines.push(`export const singlePage = ${pages.length <= 1};`);
107
+ return lines.join("\n");
108
+ }
109
+
110
+ /** Derive the pairing key when id is a paired tsx/css file. */
111
+ function keyFor(id) {
112
+ const file = id.split("?")[0].split(path.sep).join("/");
113
+ const root = appRoot.split(path.sep).join("/");
114
+ if (!file.startsWith(root + "/")) return null;
115
+ const rel = file.slice(root.length + 1);
116
+ const m = rel.match(/^(pages|components|layouts)\/([^/]+)\/([^/]+)\.tsx$/);
117
+ if (!m || m[2] !== m[3]) return null;
118
+ return m[1] + "/" + m[2];
119
+ }
120
+
121
+ return {
122
+ name: "gantry",
123
+
124
+ config(userConfig, { command }) {
125
+ const cfgRoot = userConfig.root
126
+ ? path.resolve(userConfig.root)
127
+ : process.cwd();
128
+ appRoot = path.resolve(cfgRoot, opts.appRoot ?? "..");
129
+
130
+ if (!goPort) {
131
+ try {
132
+ const cfg = JSON.parse(fs.readFileSync(path.join(appRoot, "gantry.json"), "utf8"));
133
+ goPort = cfg.port ?? 8330;
134
+ } catch {
135
+ goPort = 8330;
136
+ }
137
+ }
138
+ const target = "http://127.0.0.1:" + goPort;
139
+
140
+ return {
141
+ // gantry-web ships TypeScript source through an npm file: link;
142
+ // excluding it from prebundling keeps it in the normal
143
+ // transform pipeline (plugin-react handles its tsx), and
144
+ // deduping react avoids a second copy through the symlink.
145
+ optimizeDeps: { exclude: ["gantry-web"] },
146
+ // dedupe makes imports inside the symlinked gantry-web source
147
+ // resolve from the APP's node_modules (the package itself has
148
+ // none) and guarantees a single React instance.
149
+ resolve: { dedupe: ["react", "react-dom", "lucide-react"] },
150
+ server:
151
+ command === "serve"
152
+ ? {
153
+ proxy: {
154
+ "/api": { target },
155
+ "/gantry/ws": { target, ws: true },
156
+ },
157
+ }
158
+ : undefined,
159
+ };
160
+ },
161
+
162
+ resolveId(id) {
163
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
164
+ return null;
165
+ },
166
+
167
+ load(id) {
168
+ if (id === RESOLVED_ID) return generate();
169
+ return null;
170
+ },
171
+
172
+ transform(code, id) {
173
+ const key = keyFor(id);
174
+ if (key && code.includes("usePaired()")) {
175
+ return code.replaceAll("usePaired()", `usePaired(${JSON.stringify(key)})`);
176
+ }
177
+ return null;
178
+ },
179
+
180
+ configureServer(server) {
181
+ server.watcher.add(appRoot);
182
+ const refresh = (file) => {
183
+ const f = file.split(path.sep).join("/");
184
+ const root = appRoot.split(path.sep).join("/");
185
+ if (!f.startsWith(root)) return;
186
+ if (
187
+ !/\/(pages|components|layouts)\/[^/]+\/[^/]+\.(tsx|css)$/.test(f) &&
188
+ !f.endsWith("/index.css") &&
189
+ !f.endsWith("/app.tsx")
190
+ ) {
191
+ return;
192
+ }
193
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
194
+ if (mod) server.moduleGraph.invalidateModule(mod);
195
+ server.ws.send({ type: "full-reload" });
196
+ };
197
+ server.watcher.on("add", refresh);
198
+ server.watcher.on("unlink", refresh);
199
+ },
200
+ };
201
+ }
202
+
203
+ export default gantry;