gantry-web 0.3.5 → 0.4.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/package.json +43 -43
- package/src/Await.tsx +85 -0
- package/src/ErrorScreen.tsx +98 -0
- package/src/ResizeFrame.tsx +43 -40
- package/src/app.tsx +368 -201
- package/src/bridge.ts +95 -94
- package/src/env.ts +71 -0
- package/src/errors.ts +214 -0
- package/src/index.ts +32 -22
- package/src/resources.ts +17 -0
- package/src/router.tsx +180 -160
- package/src/service.ts +7 -2
- package/src/socket.ts +227 -187
- package/src/styles.css +247 -0
- package/src/vite/index.js +57 -0
package/src/app.tsx
CHANGED
|
@@ -1,201 +1,368 @@
|
|
|
1
|
-
/// <reference path="../types/index.d.ts" />
|
|
2
|
-
import { StrictMode, useEffect, type FC, type ReactNode } from "react";
|
|
3
|
-
import { createRoot } from "react-dom/client";
|
|
4
|
-
import { TitleBar, type TitleBarProps } from "./TitleBar";
|
|
5
|
-
import { ResizeFrame } from "./ResizeFrame";
|
|
6
|
-
import { installZoomGuard } from "./zoom";
|
|
7
|
-
import { connect, ready } from "./socket";
|
|
8
|
-
import { useRoute, redirect } from "./router";
|
|
9
|
-
import { setRegistry, type ComponentRegistry, type TeaComponentProps } from "./tea/Runtime";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
1
|
+
/// <reference path="../types/index.d.ts" />
|
|
2
|
+
import { Component, StrictMode, useEffect, type ErrorInfo as ReactErrorInfo, type FC, type ReactNode } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { TitleBar, type TitleBarProps } from "./TitleBar";
|
|
5
|
+
import { ResizeFrame } from "./ResizeFrame";
|
|
6
|
+
import { installZoomGuard } from "./zoom";
|
|
7
|
+
import { connect, ready, callGo } from "./socket";
|
|
8
|
+
import { useRoute, redirect, ParamsContext, type RouteParams } from "./router";
|
|
9
|
+
import { setRegistry, type ComponentRegistry, type TeaComponentProps } from "./tea/Runtime";
|
|
10
|
+
import { installErrorHandlers, reportError, useGantryErrors, dismissNotice, clearFatal, setDevMode, type ErrorHandlingOptions, type GantryErrorInfo } from "./errors";
|
|
11
|
+
import { ErrorScreen, type ErrorScreenProps } from "./ErrorScreen";
|
|
12
|
+
import { fetchEnv, useMode } from "./env";
|
|
13
|
+
|
|
14
|
+
/** The shape of a page module (a pages/<name>/<name>.tsx file). */
|
|
15
|
+
export interface GantryPageModule {
|
|
16
|
+
default: FC;
|
|
17
|
+
/** export const chrome = false to hide the TitleBar on this page. */
|
|
18
|
+
chrome?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Which layouts/ wrap this page: a name ("compact"), several nested
|
|
21
|
+
* outermost-first (["main", "compact"]), false for none, true to
|
|
22
|
+
* force "main" even on a chromeless page. Default: "main" if it
|
|
23
|
+
* exists (chromeless pages default to none).
|
|
24
|
+
*/
|
|
25
|
+
layout?: boolean | string | string[];
|
|
26
|
+
/** export const route = "/custom" to override the derived route. */
|
|
27
|
+
route?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GantryPage {
|
|
31
|
+
key: string;
|
|
32
|
+
route: string;
|
|
33
|
+
mod: GantryPageModule;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The generated virtual:gantry-app module: the page/component/layout
|
|
38
|
+
* registry the Vite plugin derives from the app's folders. The
|
|
39
|
+
* synthesized .gantry/main.tsx imports it and hands it to createApp.
|
|
40
|
+
* gantry-web itself must never import the virtual module: esbuild
|
|
41
|
+
* cannot resolve virtual ids, so that import would force the package
|
|
42
|
+
* out of dep prebundling - and serving it as raw node_modules source
|
|
43
|
+
* lets Vite double-instantiate modules (?v=hash vs bare URLs), which
|
|
44
|
+
* split the router's subscriber set and broke navigation.
|
|
45
|
+
*/
|
|
46
|
+
export interface GantryAppModule {
|
|
47
|
+
pages: GantryPage[];
|
|
48
|
+
components: Record<string, { default: FC }>;
|
|
49
|
+
/** Layouts by short name: layouts/main/main.tsx -> "main". */
|
|
50
|
+
layouts: Record<string, { default: FC<{ children?: ReactNode }> }>;
|
|
51
|
+
/** The root app.tsx module (default export: CreateAppOptions), or null. */
|
|
52
|
+
appConfig: { default: CreateAppOptions } | null;
|
|
53
|
+
singlePage?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The registry handed to createApp; module-level so match/layoutsFor
|
|
57
|
+
// read it without threading props.
|
|
58
|
+
let reg: GantryAppModule;
|
|
59
|
+
|
|
60
|
+
export interface CreateAppOptions {
|
|
61
|
+
/** Title shown in the TitleBar (default: none). */
|
|
62
|
+
title?: ReactNode;
|
|
63
|
+
/**
|
|
64
|
+
* TitleBar customization: title placement (titleAlign), your own
|
|
65
|
+
* controls in the left/right slots (back/forward buttons, menus),
|
|
66
|
+
* heights and reserves. The usual home for this is the optional
|
|
67
|
+
* app.tsx at the app root, default-exporting CreateAppOptions.
|
|
68
|
+
*/
|
|
69
|
+
titleBar?: Partial<TitleBarProps>;
|
|
70
|
+
/** Extra Tea components beyond the paired components/ folders. */
|
|
71
|
+
components?: ComponentRegistry;
|
|
72
|
+
/** Binding prefix when the Go side changed it from "gantry". */
|
|
73
|
+
prefix?: string;
|
|
74
|
+
/** Websocket URL override (default ws://<host>/gantry/ws). */
|
|
75
|
+
socketURL?: string;
|
|
76
|
+
/** Hide the TitleBar on every page (pages can also export chrome = false). */
|
|
77
|
+
chrome?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Crash detection and interception - on by default. A React render
|
|
80
|
+
* crash shows a full-screen error (full detail in development, a
|
|
81
|
+
* friendly card in production) instead of a white screen; Go panics
|
|
82
|
+
* and unhandled JS errors show dismissible notices in development.
|
|
83
|
+
* Replace the UI (screen), intercept (onError), or turn it off.
|
|
84
|
+
*/
|
|
85
|
+
errors?: ErrorHandlingOptions & { screen?: FC<ErrorScreenProps> };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function keyClass(key: string): string {
|
|
89
|
+
return "gantry-" + key.replace(/[^a-zA-Z0-9]+/g, "-");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function routeOf(p: GantryPage): string {
|
|
93
|
+
return p.mod.route ?? p.route;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A route compiles to typed segments: a literal must match exactly, a
|
|
97
|
+
// [param] captures one segment, a [...rest] catch-all captures one or
|
|
98
|
+
// more trailing segments.
|
|
99
|
+
type Seg =
|
|
100
|
+
| { kind: "lit"; value: string }
|
|
101
|
+
| { kind: "param"; name: string }
|
|
102
|
+
| { kind: "catchall"; name: string };
|
|
103
|
+
|
|
104
|
+
function routeIsDynamic(route: string): boolean {
|
|
105
|
+
return route.includes("[");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function cleanPath(path: string): string {
|
|
109
|
+
return path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function splitPath(path: string): string[] {
|
|
113
|
+
return path === "/" ? [] : path.replace(/^\//, "").split("/");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function compileRoute(route: string): Seg[] {
|
|
117
|
+
return splitPath(route).map((s): Seg => {
|
|
118
|
+
const cm = s.match(/^\[\.\.\.(.+)\]$/);
|
|
119
|
+
if (cm) return { kind: "catchall", name: cm[1] };
|
|
120
|
+
const pm = s.match(/^\[(.+)\]$/);
|
|
121
|
+
if (pm) return { kind: "param", name: pm[1] };
|
|
122
|
+
return { kind: "lit", value: s };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// specificity ranks candidate dynamic routes so the most concrete wins:
|
|
127
|
+
// literals beat params beat catch-alls, segment by segment.
|
|
128
|
+
function specificity(segs: Seg[]): number {
|
|
129
|
+
let score = 0;
|
|
130
|
+
for (const s of segs) {
|
|
131
|
+
score += s.kind === "lit" ? 100 : s.kind === "param" ? 10 : 1;
|
|
132
|
+
}
|
|
133
|
+
return score;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function matchSegs(segs: Seg[], parts: string[]): RouteParams | null {
|
|
137
|
+
const params: RouteParams = {};
|
|
138
|
+
for (let i = 0; i < segs.length; i++) {
|
|
139
|
+
const s = segs[i];
|
|
140
|
+
if (s.kind === "catchall") {
|
|
141
|
+
const rest = parts.slice(i);
|
|
142
|
+
if (rest.length === 0) return null; // [...x] needs at least one segment
|
|
143
|
+
params[s.name] = rest;
|
|
144
|
+
return params;
|
|
145
|
+
}
|
|
146
|
+
if (i >= parts.length || parts[i] === "") return null;
|
|
147
|
+
if (s.kind === "param") {
|
|
148
|
+
params[s.name] = parts[i];
|
|
149
|
+
} else if (parts[i] !== s.value) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return parts.length === segs.length ? params : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface RouteMatch {
|
|
157
|
+
page: GantryPage;
|
|
158
|
+
params: RouteParams;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* match resolves a pathname to a page. An exact static route wins; then
|
|
163
|
+
* dynamic routes are tried most-specific first ([id] and [...slug]).
|
|
164
|
+
* Returns null when nothing matches - the caller falls back to the index.
|
|
165
|
+
*/
|
|
166
|
+
function match(path: string): RouteMatch | null {
|
|
167
|
+
const clean = cleanPath(path);
|
|
168
|
+
const exact = reg.pages.find((p) => !routeIsDynamic(routeOf(p)) && routeOf(p) === clean);
|
|
169
|
+
if (exact) return { page: exact, params: {} };
|
|
170
|
+
|
|
171
|
+
const parts = splitPath(clean);
|
|
172
|
+
const dynamic = reg.pages
|
|
173
|
+
.filter((p) => routeIsDynamic(routeOf(p)))
|
|
174
|
+
.map((p) => ({ p, segs: compileRoute(routeOf(p)) }))
|
|
175
|
+
.sort((a, b) => specificity(b.segs) - specificity(a.segs));
|
|
176
|
+
for (const { p, segs } of dynamic) {
|
|
177
|
+
const params = matchSegs(segs, parts);
|
|
178
|
+
if (params) return { page: p, params };
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function PageHost({ page, params }: { page: GantryPage; params: RouteParams }) {
|
|
184
|
+
// Re-announce when the page key OR the concrete params change: the same
|
|
185
|
+
// dynamic page key ("pages/.../[id]") is reused across /1, /2, ... so a
|
|
186
|
+
// param change is still a navigation the Go side must hear about.
|
|
187
|
+
const paramsKey = JSON.stringify(params);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
ready(page.key, params);
|
|
190
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
191
|
+
}, [page.key, paramsKey]);
|
|
192
|
+
const Page = page.mod.default;
|
|
193
|
+
return (
|
|
194
|
+
<ParamsContext.Provider value={params}>
|
|
195
|
+
<div className={"gantry-page " + keyClass(page.key)}>
|
|
196
|
+
<Page />
|
|
197
|
+
</div>
|
|
198
|
+
</ParamsContext.Provider>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// layoutsFor resolves a page's layout selection to component functions,
|
|
203
|
+
// outermost first.
|
|
204
|
+
//
|
|
205
|
+
// (nothing) -> "main" if it exists (chromeless pages skip)
|
|
206
|
+
// layout = false -> none
|
|
207
|
+
// layout = "compact" -> that one
|
|
208
|
+
// layout = ["main","compact"] -> nested: <Main><Compact><Page/>...
|
|
209
|
+
function layoutsFor(page: GantryPage, chrome: boolean): FC<{ children?: ReactNode }>[] {
|
|
210
|
+
const sel = page.mod.layout;
|
|
211
|
+
let names: string[];
|
|
212
|
+
if (sel === false) {
|
|
213
|
+
names = [];
|
|
214
|
+
} else if (typeof sel === "string") {
|
|
215
|
+
names = [sel];
|
|
216
|
+
} else if (Array.isArray(sel)) {
|
|
217
|
+
names = sel;
|
|
218
|
+
} else if (sel === true) {
|
|
219
|
+
names = reg.layouts.main ? ["main"] : [];
|
|
220
|
+
} else {
|
|
221
|
+
// Default: the "main" layout on normal pages; chromeless pages
|
|
222
|
+
// (widgets, popups) are their own surfaces and skip it.
|
|
223
|
+
names = chrome && reg.layouts.main ? ["main"] : [];
|
|
224
|
+
}
|
|
225
|
+
const out: FC<{ children?: ReactNode }>[] = [];
|
|
226
|
+
for (const n of names) {
|
|
227
|
+
const mod = reg.layouts[n];
|
|
228
|
+
if (mod?.default) {
|
|
229
|
+
out.push(mod.default);
|
|
230
|
+
} else {
|
|
231
|
+
console.warn(`gantry: page ${page.key} wants unknown layout "${n}" (have: ${Object.keys(reg.layouts).join(", ") || "none"})`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ErrorBoundary catches render crashes below it. It wraps the page
|
|
238
|
+
// content only - ResizeFrame and TitleBar stay outside, so a crashed
|
|
239
|
+
// page keeps its drag and close controls.
|
|
240
|
+
class ErrorBoundary extends Component<{ children: ReactNode }, { crashed: boolean }> {
|
|
241
|
+
state = { crashed: false };
|
|
242
|
+
static getDerivedStateFromError() {
|
|
243
|
+
return { crashed: true };
|
|
244
|
+
}
|
|
245
|
+
componentDidCatch(error: Error, info: ReactErrorInfo) {
|
|
246
|
+
reportError({
|
|
247
|
+
kind: "react-render",
|
|
248
|
+
code: "react.render",
|
|
249
|
+
message: error.message,
|
|
250
|
+
stack: error.stack,
|
|
251
|
+
componentStack: info.componentStack ?? undefined,
|
|
252
|
+
time: new Date().toISOString(),
|
|
253
|
+
origin: "js",
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
render() {
|
|
257
|
+
// The fatal overlay renders from the store (ErrorHost); the crashed
|
|
258
|
+
// subtree just goes away.
|
|
259
|
+
return this.state.crashed ? null : this.props.children;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ErrorHost renders the (default or custom) error UI from the store:
|
|
264
|
+
// the fatal overlay and the notice banners.
|
|
265
|
+
function ErrorHost({ options }: { options: CreateAppOptions }) {
|
|
266
|
+
const { fatal, notices } = useGantryErrors();
|
|
267
|
+
const mode = useMode();
|
|
268
|
+
if (options.errors?.enabled === false) return null;
|
|
269
|
+
const Screen = options.errors?.screen ?? ErrorScreen;
|
|
270
|
+
return (
|
|
271
|
+
<>
|
|
272
|
+
{fatal && <Screen error={fatal} mode={mode} variant="fatal" onDismiss={clearFatal} />}
|
|
273
|
+
{notices.map((e, i) => (
|
|
274
|
+
// Newest first: the CSS shows only the first banner, dismissing
|
|
275
|
+
// it reveals the next.
|
|
276
|
+
<Screen key={i} error={e} mode={mode} variant="notice" onDismiss={() => dismissNotice(i)} />
|
|
277
|
+
)).reverse()}
|
|
278
|
+
</>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function AppRoot({ options }: { options: CreateAppOptions }) {
|
|
283
|
+
const path = useRoute();
|
|
284
|
+
const result = match(path);
|
|
285
|
+
const indexPage = reg.pages.find((p) => routeOf(p) === "/");
|
|
286
|
+
// A URL that matches no page (deleted during dev, stale bookmark) falls
|
|
287
|
+
// back to the index page - normalize the address so Links light up. A
|
|
288
|
+
// dynamic match is NOT a fallback: its URL is already canonical, so we
|
|
289
|
+
// must never redirect it back to the [id] pattern.
|
|
290
|
+
const matched = result !== null;
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
if (!matched && indexPage) {
|
|
293
|
+
redirect("/");
|
|
294
|
+
}
|
|
295
|
+
}, [matched, indexPage]);
|
|
296
|
+
const page = result?.page ?? indexPage;
|
|
297
|
+
if (!page) {
|
|
298
|
+
return <div className="gantry-app">No pages found - add pages/index/index.tsx</div>;
|
|
299
|
+
}
|
|
300
|
+
const params = result?.params ?? {};
|
|
301
|
+
const chrome = options.chrome !== false && page.mod.chrome !== false;
|
|
302
|
+
let content = <PageHost page={page} params={params} />;
|
|
303
|
+
for (const Layout of layoutsFor(page, chrome).reverse()) {
|
|
304
|
+
content = <Layout>{content}</Layout>;
|
|
305
|
+
}
|
|
306
|
+
// The boundary wraps the page and layouts only: a render crash takes
|
|
307
|
+
// the content down, never the window chrome - the error overlay
|
|
308
|
+
// appears and the TitleBar keeps drag/close working.
|
|
309
|
+
return (
|
|
310
|
+
<div className="gantry-app">
|
|
311
|
+
<ResizeFrame prefix={options.prefix} />
|
|
312
|
+
{chrome && <TitleBar title={options.title} prefix={options.prefix} {...options.titleBar} />}
|
|
313
|
+
<ErrorBoundary>{content}</ErrorBoundary>
|
|
314
|
+
<ErrorHost options={options} />
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* createApp boots a Gantry frontend: zoom guard, websocket, component
|
|
321
|
+
* registry, TitleBar, the root layout (layout.tsx at the app root, if
|
|
322
|
+
* present), and the page router (single-page mode when only pages/index
|
|
323
|
+
* exists). The synthesized .gantry/main.tsx calls this with the
|
|
324
|
+
* virtual:gantry-app module (`import * as app from "virtual:gantry-app"`);
|
|
325
|
+
* an app only touches it to pass options.
|
|
326
|
+
*/
|
|
327
|
+
export function createApp(app: GantryAppModule, options: CreateAppOptions = {}): void {
|
|
328
|
+
reg = app;
|
|
329
|
+
// The optional root app.tsx wins over the synthesized defaults, so
|
|
330
|
+
// apps customize everything without owning the entry file.
|
|
331
|
+
if (reg.appConfig?.default) {
|
|
332
|
+
options = { ...options, ...reg.appConfig.default };
|
|
333
|
+
}
|
|
334
|
+
installZoomGuard();
|
|
335
|
+
installErrorHandlers(options.errors ?? {});
|
|
336
|
+
connect(options.socketURL);
|
|
337
|
+
// Resolve the mode for the error UI (dev shows full detail), and
|
|
338
|
+
// surface a crash from the previous run if Go recorded one.
|
|
339
|
+
if (options.errors?.enabled !== false) {
|
|
340
|
+
fetchEnv()
|
|
341
|
+
.then((env) => setDevMode(env.mode === "development"))
|
|
342
|
+
.catch(() => {});
|
|
343
|
+
callGo("gantry", "errors")
|
|
344
|
+
.then((list) => {
|
|
345
|
+
const errs = (list ?? []) as (Omit<GantryErrorInfo, "origin"> & { kind: string })[];
|
|
346
|
+
const crash = errs.filter((e) => e.kind === "process-crash").at(-1);
|
|
347
|
+
if (crash) reportError({ ...crash, origin: "go" });
|
|
348
|
+
})
|
|
349
|
+
.catch(() => {});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const registry: ComponentRegistry = {};
|
|
353
|
+
for (const [key, mod] of Object.entries(reg.components)) {
|
|
354
|
+
if (typeof mod.default === "function") {
|
|
355
|
+
registry[key] = mod.default as FC<TeaComponentProps>;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
Object.assign(registry, options.components);
|
|
359
|
+
setRegistry(registry);
|
|
360
|
+
|
|
361
|
+
const container = document.getElementById("root");
|
|
362
|
+
if (!container) throw new Error("gantry: no #root element");
|
|
363
|
+
createRoot(container).render(
|
|
364
|
+
<StrictMode>
|
|
365
|
+
<AppRoot options={options} />
|
|
366
|
+
</StrictMode>,
|
|
367
|
+
);
|
|
368
|
+
}
|