@lotics/cli 0.54.2 → 0.55.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/dist/dev/wrapper_page.d.ts +3 -3
- package/dist/dev/wrapper_page.js +24 -10
- package/dist/src/cli.js +136 -54
- package/dist/starter_template.d.ts +1 -1
- package/dist/starter_template.js +117 -46
- package/dist/starter_template.test.js +20 -0
- package/package.json +1 -1
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
25
25
|
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
26
26
|
* the iframe aborts with { id, type: "abort" }.
|
|
27
|
-
*
|
|
28
|
-
* via
|
|
29
|
-
* params }
|
|
27
|
+
* urlState (useUrlState + router adapter): the iframe reads/writes the wrapper's
|
|
28
|
+
* address bar via urlState.get/set/go; set push/replace and go drive history,
|
|
29
|
+
* and back/forward broadcast { type: "url-state", params } back to the iframe.
|
|
30
30
|
*/
|
|
31
31
|
export interface WrapperPageArgs {
|
|
32
32
|
app_name: string;
|
package/dist/dev/wrapper_page.js
CHANGED
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
25
25
|
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
26
26
|
* the iframe aborts with { id, type: "abort" }.
|
|
27
|
-
*
|
|
28
|
-
* via
|
|
29
|
-
* params }
|
|
27
|
+
* urlState (useUrlState + router adapter): the iframe reads/writes the wrapper's
|
|
28
|
+
* address bar via urlState.get/set/go; set push/replace and go drive history,
|
|
29
|
+
* and back/forward broadcast { type: "url-state", params } back to the iframe.
|
|
30
30
|
*/
|
|
31
31
|
export function buildWrapperPage(args) {
|
|
32
32
|
const { app_name, app_id, workspace_id, vite_url, api_url } = args;
|
|
@@ -185,6 +185,7 @@ export function buildWrapperPage(args) {
|
|
|
185
185
|
});
|
|
186
186
|
return out;
|
|
187
187
|
}
|
|
188
|
+
var lastPushAt = 0;
|
|
188
189
|
function handleUrlStateSet(payload) {
|
|
189
190
|
const params = (payload && payload.params) || {};
|
|
190
191
|
const sp = new URLSearchParams(window.location.search);
|
|
@@ -197,10 +198,21 @@ export function buildWrapperPage(args) {
|
|
|
197
198
|
});
|
|
198
199
|
const qs = sp.toString();
|
|
199
200
|
const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
// push adds a back-able entry (the router adapter); replace for filter
|
|
202
|
+
// churn. Flood guard: coerce sub-100ms pushes to replace. pushState/
|
|
203
|
+
// replaceState don't fire popstate, so no echo. Mirrors the production host.
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
if (payload && payload.push && now - lastPushAt > 100) {
|
|
206
|
+
lastPushAt = now;
|
|
207
|
+
window.history.pushState(null, "", url);
|
|
208
|
+
} else {
|
|
209
|
+
window.history.replaceState(null, "", url);
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
function handleUrlStateGo(payload) {
|
|
214
|
+
const delta = payload && payload.delta;
|
|
215
|
+
if (typeof delta === "number") window.history.go(delta);
|
|
204
216
|
return undefined;
|
|
205
217
|
}
|
|
206
218
|
|
|
@@ -269,6 +281,8 @@ export function buildWrapperPage(args) {
|
|
|
269
281
|
? readUrlParams()
|
|
270
282
|
: msg.op === "urlState.set"
|
|
271
283
|
? handleUrlStateSet(msg.payload)
|
|
284
|
+
: msg.op === "urlState.go"
|
|
285
|
+
? handleUrlStateGo(msg.payload)
|
|
272
286
|
: await rpc(msg.op, msg.payload);
|
|
273
287
|
const ms = Math.round(performance.now() - startedAt);
|
|
274
288
|
console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
|
|
@@ -286,9 +300,9 @@ export function buildWrapperPage(args) {
|
|
|
286
300
|
}
|
|
287
301
|
});
|
|
288
302
|
|
|
289
|
-
//
|
|
290
|
-
// re-
|
|
291
|
-
// so there's no echo).
|
|
303
|
+
// Browser/adapter back/forward → broadcast the new params so useUrlState and
|
|
304
|
+
// the router adapter re-hydrate (the app's own set writes use push/replace
|
|
305
|
+
// State — no popstate — so there's no echo). Mirrors the production host.
|
|
292
306
|
window.addEventListener("popstate", function () {
|
|
293
307
|
iframe.contentWindow.postMessage(
|
|
294
308
|
{ type: "url-state", params: readUrlParams() },
|
package/dist/src/cli.js
CHANGED
|
@@ -30242,7 +30242,7 @@ import { tmpdir } from "node:os";
|
|
|
30242
30242
|
|
|
30243
30243
|
// src/starter_template.ts
|
|
30244
30244
|
var STARTER_FALLBACK_UI_VERSION = "6.1.0";
|
|
30245
|
-
var STARTER_FALLBACK_SDK_VERSION = "0.
|
|
30245
|
+
var STARTER_FALLBACK_SDK_VERSION = "0.40.0";
|
|
30246
30246
|
var STARTER_REACT_NATIVE_VERSION = "0.85.3";
|
|
30247
30247
|
function buildStarterTemplate(args) {
|
|
30248
30248
|
const uiVersion = args.ui_version ?? `^${STARTER_FALLBACK_UI_VERSION}`;
|
|
@@ -30284,7 +30284,10 @@ function buildStarterTemplate(args) {
|
|
|
30284
30284
|
"react-dom": "^19.0.0",
|
|
30285
30285
|
"react-native": STARTER_REACT_NATIVE_VERSION,
|
|
30286
30286
|
"react-native-svg": "^15.0.0",
|
|
30287
|
-
"react-native-web": "^0.21.0"
|
|
30287
|
+
"react-native-web": "^0.21.0",
|
|
30288
|
+
// In-app routing for the scaffolded list→detail example. The app owns
|
|
30289
|
+
// its routing; the SDK's `isEmbedded()` picks memory vs browser history.
|
|
30290
|
+
"react-router-dom": "^7.0.0"
|
|
30288
30291
|
},
|
|
30289
30292
|
devDependencies: {
|
|
30290
30293
|
"@testing-library/react": "^16.1.0",
|
|
@@ -30497,6 +30500,10 @@ export default defineConfig({
|
|
|
30497
30500
|
// the hooks dispatcher the instant a hook (useAgentRun / useQuery)
|
|
30498
30501
|
// runs in a rendered App tree.
|
|
30499
30502
|
"@lotics/app-sdk",
|
|
30503
|
+
// react-router-dom (App's router) also ships compiled dist JS \u2014 pin it
|
|
30504
|
+
// into the shared chunk so its hooks don't split the react instance
|
|
30505
|
+
// ("Invalid hook call") when a test renders the routed App tree.
|
|
30506
|
+
"react-router-dom",
|
|
30500
30507
|
],
|
|
30501
30508
|
esbuildOptions: {
|
|
30502
30509
|
resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
|
|
@@ -30565,59 +30572,110 @@ mount(
|
|
|
30565
30572
|
},
|
|
30566
30573
|
{
|
|
30567
30574
|
path: "src/App.tsx",
|
|
30568
|
-
//
|
|
30569
|
-
//
|
|
30570
|
-
// -
|
|
30571
|
-
//
|
|
30572
|
-
//
|
|
30573
|
-
//
|
|
30574
|
-
//
|
|
30575
|
-
//
|
|
30576
|
-
//
|
|
30577
|
-
//
|
|
30578
|
-
//
|
|
30579
|
-
//
|
|
30580
|
-
|
|
30581
|
-
|
|
30582
|
-
|
|
30575
|
+
// Default scaffold = a minimal in-app router example (a list screen and a
|
|
30576
|
+
// detail screen), so a new app starts with the recommended routing shape:
|
|
30577
|
+
// - The app uses react-router via `AppRouter` (from @lotics/app-sdk/router),
|
|
30578
|
+
// which makes screens real addressable URLs — embedded, they live in the
|
|
30579
|
+
// host address bar (?_loc=…) and the browser Back/Forward walk screens;
|
|
30580
|
+
// standalone, real path URLs.
|
|
30581
|
+
// - The full-container layout pattern is preserved in `Screen`: an outer
|
|
30582
|
+
// <View flex:1> claims the iframe height (index.html sets
|
|
30583
|
+
// html/body/#root to 100% + #root is a flex column). Keep that chain
|
|
30584
|
+
// plain — @lotics/ui/stack wraps children in an unstyled <View> that
|
|
30585
|
+
// breaks `flex: 1` propagation to a fill-remaining-space child.
|
|
30586
|
+
// A single-screen app can delete the router and render one Screen directly.
|
|
30587
|
+
content: `import type { ReactNode } from "react";
|
|
30588
|
+
import { View } from "react-native";
|
|
30589
|
+
import { useNavigate, useParams } from "react-router-dom";
|
|
30590
|
+
import { AppRouter } from "@lotics/app-sdk/router";
|
|
30583
30591
|
import { Card } from "@lotics/ui/card";
|
|
30584
30592
|
import { Text } from "@lotics/ui/text";
|
|
30585
30593
|
import { Button } from "@lotics/ui/button";
|
|
30586
30594
|
|
|
30587
|
-
//
|
|
30588
|
-
//
|
|
30589
|
-
//
|
|
30590
|
-
//
|
|
30591
|
-
//
|
|
30592
|
-
|
|
30595
|
+
// AppRouter makes the app's screens real, addressable URLs \u2014 write plain
|
|
30596
|
+
// react-router (useNavigate / useParams / <Link>) and it handles both modes:
|
|
30597
|
+
// - Embedded in the Lotics host: the current screen lives in the host address
|
|
30598
|
+
// bar (?_loc=\u2026) \u2014 shareable + refresh-survivable \u2014 and the browser Back /
|
|
30599
|
+
// Forward buttons walk app screens (then leave the app).
|
|
30600
|
+
// - Standalone at <slug>.lotics.app: a normal browser router with real path URLs.
|
|
30601
|
+
|
|
30602
|
+
const ITEMS = [
|
|
30603
|
+
{ id: "1", name: "First item" },
|
|
30604
|
+
{ id: "2", name: "Second item" },
|
|
30605
|
+
{ id: "3", name: "Third item" },
|
|
30606
|
+
];
|
|
30607
|
+
|
|
30608
|
+
// Outer <View flex:1> claims the full iframe height \u2014 works because index.html
|
|
30609
|
+
// sets html/body/#root to 100% and #root is a flex column. Keep this flex chain
|
|
30610
|
+
// plain (not @lotics/ui/stack) so a fill-remaining-space child can claim height.
|
|
30611
|
+
function Screen({ children }: { children: ReactNode }) {
|
|
30593
30612
|
return (
|
|
30594
|
-
<View
|
|
30595
|
-
style={{
|
|
30596
|
-
|
|
30597
|
-
|
|
30598
|
-
|
|
30599
|
-
|
|
30600
|
-
|
|
30601
|
-
|
|
30602
|
-
|
|
30603
|
-
|
|
30604
|
-
|
|
30605
|
-
|
|
30606
|
-
|
|
30607
|
-
|
|
30608
|
-
|
|
30609
|
-
|
|
30610
|
-
|
|
30613
|
+
<View style={{ flex: 1, padding: 24, alignItems: "center" }}>
|
|
30614
|
+
<View style={{ maxWidth: 640, width: "100%", gap: 16 }}>{children}</View>
|
|
30615
|
+
</View>
|
|
30616
|
+
);
|
|
30617
|
+
}
|
|
30618
|
+
|
|
30619
|
+
function ListScreen() {
|
|
30620
|
+
const navigate = useNavigate();
|
|
30621
|
+
return (
|
|
30622
|
+
<Screen>
|
|
30623
|
+
<Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
|
|
30624
|
+
<Text color="muted">
|
|
30625
|
+
Tap an item to open its detail screen \u2014 the app routes itself. Edit{" "}
|
|
30626
|
+
<Text weight="medium">src/App.tsx</Text> and run{" "}
|
|
30627
|
+
<Text weight="medium">lotics app deploy</Text> to publish.
|
|
30628
|
+
</Text>
|
|
30629
|
+
{ITEMS.map((item) => (
|
|
30630
|
+
<Card key={item.id}>
|
|
30631
|
+
<View
|
|
30632
|
+
style={{
|
|
30633
|
+
padding: 16,
|
|
30634
|
+
flexDirection: "row",
|
|
30635
|
+
alignItems: "center",
|
|
30636
|
+
justifyContent: "space-between",
|
|
30637
|
+
gap: 12,
|
|
30638
|
+
}}
|
|
30639
|
+
>
|
|
30640
|
+
<Text weight="medium">{item.name}</Text>
|
|
30641
|
+
<Button title="Open" color="primary" onPress={() => navigate("/item/" + item.id)} />
|
|
30611
30642
|
</View>
|
|
30612
30643
|
</Card>
|
|
30613
|
-
|
|
30614
|
-
|
|
30615
|
-
|
|
30616
|
-
|
|
30644
|
+
))}
|
|
30645
|
+
</Screen>
|
|
30646
|
+
);
|
|
30647
|
+
}
|
|
30648
|
+
|
|
30649
|
+
function ItemDetailScreen() {
|
|
30650
|
+
const { id } = useParams();
|
|
30651
|
+
const navigate = useNavigate();
|
|
30652
|
+
const item = ITEMS.find((i) => i.id === id);
|
|
30653
|
+
return (
|
|
30654
|
+
<Screen>
|
|
30655
|
+
{/* An in-app Back control; navigate(-1) walks the history (the browser Back
|
|
30656
|
+
button walks app screens too). */}
|
|
30657
|
+
<View style={{ alignItems: "flex-start" }}>
|
|
30658
|
+
<Button title="Back" onPress={() => navigate(-1)} />
|
|
30617
30659
|
</View>
|
|
30618
|
-
|
|
30660
|
+
<Text size="xl" weight="semibold">{item ? item.name : "Not found"}</Text>
|
|
30661
|
+
<Card>
|
|
30662
|
+
<View style={{ padding: 16, gap: 8 }}>
|
|
30663
|
+
<Text>Detail for item {id}.</Text>
|
|
30664
|
+
<Text color="muted">Reached via in-app navigation, not a host route.</Text>
|
|
30665
|
+
</View>
|
|
30666
|
+
</Card>
|
|
30667
|
+
</Screen>
|
|
30619
30668
|
);
|
|
30620
30669
|
}
|
|
30670
|
+
|
|
30671
|
+
const routes = [
|
|
30672
|
+
{ path: "/", element: <ListScreen /> },
|
|
30673
|
+
{ path: "/item/:id", element: <ItemDetailScreen /> },
|
|
30674
|
+
];
|
|
30675
|
+
|
|
30676
|
+
export default function App() {
|
|
30677
|
+
return <AppRouter routes={routes} />;
|
|
30678
|
+
}
|
|
30621
30679
|
`
|
|
30622
30680
|
},
|
|
30623
30681
|
{
|
|
@@ -30696,9 +30754,9 @@ import { render } from "@testing-library/react";
|
|
|
30696
30754
|
import App from "./App";
|
|
30697
30755
|
|
|
30698
30756
|
describe("App", () => {
|
|
30699
|
-
test("renders
|
|
30757
|
+
test("renders the default route", () => {
|
|
30700
30758
|
const { container } = render(<App />);
|
|
30701
|
-
expect(container).
|
|
30759
|
+
expect(container.textContent).toContain("First item");
|
|
30702
30760
|
});
|
|
30703
30761
|
});
|
|
30704
30762
|
`
|
|
@@ -30782,6 +30840,16 @@ import { Text } from "@lotics/ui/text";
|
|
|
30782
30840
|
in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
|
|
30783
30841
|
the full export list at https://www.npmjs.com/package/@lotics/ui.
|
|
30784
30842
|
|
|
30843
|
+
## Routing
|
|
30844
|
+
|
|
30845
|
+
\`src/App.tsx\` ships a minimal in-app router. Write plain react-router and wrap
|
|
30846
|
+
your routes in \`AppRouter\` from \`@lotics/app-sdk/router\` \u2014 it makes screens real,
|
|
30847
|
+
addressable URLs in both modes: embedded in the Lotics host the current screen
|
|
30848
|
+
lives in the host address bar (\`?_loc=\u2026\`, shareable + refresh-survivable) and the
|
|
30849
|
+
browser Back/Forward walk app screens; standalone (\`<slug>.lotics.app\`) it's a
|
|
30850
|
+
normal browser router with real path URLs. A single-screen app can drop the
|
|
30851
|
+
router and render one screen directly.
|
|
30852
|
+
|
|
30785
30853
|
See https://lotics.ai/docs/app-sdk for the SDK reference.
|
|
30786
30854
|
`
|
|
30787
30855
|
}
|
|
@@ -31095,6 +31163,7 @@ function buildWrapperPage(args) {
|
|
|
31095
31163
|
});
|
|
31096
31164
|
return out;
|
|
31097
31165
|
}
|
|
31166
|
+
var lastPushAt = 0;
|
|
31098
31167
|
function handleUrlStateSet(payload) {
|
|
31099
31168
|
const params = (payload && payload.params) || {};
|
|
31100
31169
|
const sp = new URLSearchParams(window.location.search);
|
|
@@ -31107,10 +31176,21 @@ function buildWrapperPage(args) {
|
|
|
31107
31176
|
});
|
|
31108
31177
|
const qs = sp.toString();
|
|
31109
31178
|
const url = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash;
|
|
31110
|
-
//
|
|
31111
|
-
//
|
|
31112
|
-
|
|
31113
|
-
|
|
31179
|
+
// push adds a back-able entry (the router adapter); replace for filter
|
|
31180
|
+
// churn. Flood guard: coerce sub-100ms pushes to replace. pushState/
|
|
31181
|
+
// replaceState don't fire popstate, so no echo. Mirrors the production host.
|
|
31182
|
+
const now = Date.now();
|
|
31183
|
+
if (payload && payload.push && now - lastPushAt > 100) {
|
|
31184
|
+
lastPushAt = now;
|
|
31185
|
+
window.history.pushState(null, "", url);
|
|
31186
|
+
} else {
|
|
31187
|
+
window.history.replaceState(null, "", url);
|
|
31188
|
+
}
|
|
31189
|
+
return undefined;
|
|
31190
|
+
}
|
|
31191
|
+
function handleUrlStateGo(payload) {
|
|
31192
|
+
const delta = payload && payload.delta;
|
|
31193
|
+
if (typeof delta === "number") window.history.go(delta);
|
|
31114
31194
|
return undefined;
|
|
31115
31195
|
}
|
|
31116
31196
|
|
|
@@ -31179,6 +31259,8 @@ function buildWrapperPage(args) {
|
|
|
31179
31259
|
? readUrlParams()
|
|
31180
31260
|
: msg.op === "urlState.set"
|
|
31181
31261
|
? handleUrlStateSet(msg.payload)
|
|
31262
|
+
: msg.op === "urlState.go"
|
|
31263
|
+
? handleUrlStateGo(msg.payload)
|
|
31182
31264
|
: await rpc(msg.op, msg.payload);
|
|
31183
31265
|
const ms = Math.round(performance.now() - startedAt);
|
|
31184
31266
|
console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
|
|
@@ -31196,9 +31278,9 @@ function buildWrapperPage(args) {
|
|
|
31196
31278
|
}
|
|
31197
31279
|
});
|
|
31198
31280
|
|
|
31199
|
-
//
|
|
31200
|
-
// re-
|
|
31201
|
-
// so there's no echo).
|
|
31281
|
+
// Browser/adapter back/forward \u2192 broadcast the new params so useUrlState and
|
|
31282
|
+
// the router adapter re-hydrate (the app's own set writes use push/replace
|
|
31283
|
+
// State \u2014 no popstate \u2014 so there's no echo). Mirrors the production host.
|
|
31202
31284
|
window.addEventListener("popstate", function () {
|
|
31203
31285
|
iframe.contentWindow.postMessage(
|
|
31204
31286
|
{ type: "url-state", params: readUrlParams() },
|
|
@@ -42,7 +42,7 @@ export interface StarterFile {
|
|
|
42
42
|
* fall back here when the lookup fails.
|
|
43
43
|
*/
|
|
44
44
|
export declare const STARTER_FALLBACK_UI_VERSION = "6.1.0";
|
|
45
|
-
export declare const STARTER_FALLBACK_SDK_VERSION = "0.
|
|
45
|
+
export declare const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
|
|
46
46
|
/**
|
|
47
47
|
* react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
|
|
48
48
|
* an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
|
package/dist/starter_template.js
CHANGED
|
@@ -38,7 +38,10 @@
|
|
|
38
38
|
* fall back here when the lookup fails.
|
|
39
39
|
*/
|
|
40
40
|
export const STARTER_FALLBACK_UI_VERSION = "6.1.0";
|
|
41
|
-
|
|
41
|
+
// Must be ≥ the release that added `@lotics/app-sdk/router` (`AppRouter`, used by
|
|
42
|
+
// the scaffolded App.tsx) — an older offline pin won't resolve the subpath and
|
|
43
|
+
// the scaffold won't typecheck.
|
|
44
|
+
export const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
|
|
42
45
|
/**
|
|
43
46
|
* react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
|
|
44
47
|
* an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
|
|
@@ -94,6 +97,9 @@ export function buildStarterTemplate(args) {
|
|
|
94
97
|
"react-native": STARTER_REACT_NATIVE_VERSION,
|
|
95
98
|
"react-native-svg": "^15.0.0",
|
|
96
99
|
"react-native-web": "^0.21.0",
|
|
100
|
+
// In-app routing for the scaffolded list→detail example. The app owns
|
|
101
|
+
// its routing; the SDK's `isEmbedded()` picks memory vs browser history.
|
|
102
|
+
"react-router-dom": "^7.0.0",
|
|
97
103
|
},
|
|
98
104
|
devDependencies: {
|
|
99
105
|
"@testing-library/react": "^16.1.0",
|
|
@@ -299,6 +305,10 @@ export default defineConfig({
|
|
|
299
305
|
// the hooks dispatcher the instant a hook (useAgentRun / useQuery)
|
|
300
306
|
// runs in a rendered App tree.
|
|
301
307
|
"@lotics/app-sdk",
|
|
308
|
+
// react-router-dom (App's router) also ships compiled dist JS — pin it
|
|
309
|
+
// into the shared chunk so its hooks don't split the react instance
|
|
310
|
+
// ("Invalid hook call") when a test renders the routed App tree.
|
|
311
|
+
"react-router-dom",
|
|
302
312
|
],
|
|
303
313
|
esbuildOptions: {
|
|
304
314
|
resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".jsx", ".js", ".json"],
|
|
@@ -367,59 +377,110 @@ mount(
|
|
|
367
377
|
},
|
|
368
378
|
{
|
|
369
379
|
path: "src/App.tsx",
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
// -
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
380
|
+
// Default scaffold = a minimal in-app router example (a list screen and a
|
|
381
|
+
// detail screen), so a new app starts with the recommended routing shape:
|
|
382
|
+
// - The app uses react-router via `AppRouter` (from @lotics/app-sdk/router),
|
|
383
|
+
// which makes screens real addressable URLs — embedded, they live in the
|
|
384
|
+
// host address bar (?_loc=…) and the browser Back/Forward walk screens;
|
|
385
|
+
// standalone, real path URLs.
|
|
386
|
+
// - The full-container layout pattern is preserved in `Screen`: an outer
|
|
387
|
+
// <View flex:1> claims the iframe height (index.html sets
|
|
388
|
+
// html/body/#root to 100% + #root is a flex column). Keep that chain
|
|
389
|
+
// plain — @lotics/ui/stack wraps children in an unstyled <View> that
|
|
390
|
+
// breaks `flex: 1` propagation to a fill-remaining-space child.
|
|
391
|
+
// A single-screen app can delete the router and render one Screen directly.
|
|
392
|
+
content: `import type { ReactNode } from "react";
|
|
393
|
+
import { View } from "react-native";
|
|
394
|
+
import { useNavigate, useParams } from "react-router-dom";
|
|
395
|
+
import { AppRouter } from "@lotics/app-sdk/router";
|
|
385
396
|
import { Card } from "@lotics/ui/card";
|
|
386
397
|
import { Text } from "@lotics/ui/text";
|
|
387
398
|
import { Button } from "@lotics/ui/button";
|
|
388
399
|
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
400
|
+
// AppRouter makes the app's screens real, addressable URLs — write plain
|
|
401
|
+
// react-router (useNavigate / useParams / <Link>) and it handles both modes:
|
|
402
|
+
// - Embedded in the Lotics host: the current screen lives in the host address
|
|
403
|
+
// bar (?_loc=…) — shareable + refresh-survivable — and the browser Back /
|
|
404
|
+
// Forward buttons walk app screens (then leave the app).
|
|
405
|
+
// - Standalone at <slug>.lotics.app: a normal browser router with real path URLs.
|
|
406
|
+
|
|
407
|
+
const ITEMS = [
|
|
408
|
+
{ id: "1", name: "First item" },
|
|
409
|
+
{ id: "2", name: "Second item" },
|
|
410
|
+
{ id: "3", name: "Third item" },
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
// Outer <View flex:1> claims the full iframe height — works because index.html
|
|
414
|
+
// sets html/body/#root to 100% and #root is a flex column. Keep this flex chain
|
|
415
|
+
// plain (not @lotics/ui/stack) so a fill-remaining-space child can claim height.
|
|
416
|
+
function Screen({ children }: { children: ReactNode }) {
|
|
417
|
+
return (
|
|
418
|
+
<View style={{ flex: 1, padding: 24, alignItems: "center" }}>
|
|
419
|
+
<View style={{ maxWidth: 640, width: "100%", gap: 16 }}>{children}</View>
|
|
420
|
+
</View>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function ListScreen() {
|
|
425
|
+
const navigate = useNavigate();
|
|
395
426
|
return (
|
|
396
|
-
<
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
427
|
+
<Screen>
|
|
428
|
+
<Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
|
|
429
|
+
<Text color="muted">
|
|
430
|
+
Tap an item to open its detail screen — the app routes itself. Edit{" "}
|
|
431
|
+
<Text weight="medium">src/App.tsx</Text> and run{" "}
|
|
432
|
+
<Text weight="medium">lotics app deploy</Text> to publish.
|
|
433
|
+
</Text>
|
|
434
|
+
{ITEMS.map((item) => (
|
|
435
|
+
<Card key={item.id}>
|
|
436
|
+
<View
|
|
437
|
+
style={{
|
|
438
|
+
padding: 16,
|
|
439
|
+
flexDirection: "row",
|
|
440
|
+
alignItems: "center",
|
|
441
|
+
justifyContent: "space-between",
|
|
442
|
+
gap: 12,
|
|
443
|
+
}}
|
|
444
|
+
>
|
|
445
|
+
<Text weight="medium">{item.name}</Text>
|
|
446
|
+
<Button title="Open" color="primary" onPress={() => navigate("/item/" + item.id)} />
|
|
413
447
|
</View>
|
|
414
448
|
</Card>
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
449
|
+
))}
|
|
450
|
+
</Screen>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function ItemDetailScreen() {
|
|
455
|
+
const { id } = useParams();
|
|
456
|
+
const navigate = useNavigate();
|
|
457
|
+
const item = ITEMS.find((i) => i.id === id);
|
|
458
|
+
return (
|
|
459
|
+
<Screen>
|
|
460
|
+
{/* An in-app Back control; navigate(-1) walks the history (the browser Back
|
|
461
|
+
button walks app screens too). */}
|
|
462
|
+
<View style={{ alignItems: "flex-start" }}>
|
|
463
|
+
<Button title="Back" onPress={() => navigate(-1)} />
|
|
419
464
|
</View>
|
|
420
|
-
|
|
465
|
+
<Text size="xl" weight="semibold">{item ? item.name : "Not found"}</Text>
|
|
466
|
+
<Card>
|
|
467
|
+
<View style={{ padding: 16, gap: 8 }}>
|
|
468
|
+
<Text>Detail for item {id}.</Text>
|
|
469
|
+
<Text color="muted">Reached via in-app navigation, not a host route.</Text>
|
|
470
|
+
</View>
|
|
471
|
+
</Card>
|
|
472
|
+
</Screen>
|
|
421
473
|
);
|
|
422
474
|
}
|
|
475
|
+
|
|
476
|
+
const routes = [
|
|
477
|
+
{ path: "/", element: <ListScreen /> },
|
|
478
|
+
{ path: "/item/:id", element: <ItemDetailScreen /> },
|
|
479
|
+
];
|
|
480
|
+
|
|
481
|
+
export default function App() {
|
|
482
|
+
return <AppRouter routes={routes} />;
|
|
483
|
+
}
|
|
423
484
|
`,
|
|
424
485
|
},
|
|
425
486
|
{
|
|
@@ -498,9 +559,9 @@ import { render } from "@testing-library/react";
|
|
|
498
559
|
import App from "./App";
|
|
499
560
|
|
|
500
561
|
describe("App", () => {
|
|
501
|
-
test("renders
|
|
562
|
+
test("renders the default route", () => {
|
|
502
563
|
const { container } = render(<App />);
|
|
503
|
-
expect(container).
|
|
564
|
+
expect(container.textContent).toContain("First item");
|
|
504
565
|
});
|
|
505
566
|
});
|
|
506
567
|
`,
|
|
@@ -584,6 +645,16 @@ import { Text } from "@lotics/ui/text";
|
|
|
584
645
|
in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
|
|
585
646
|
the full export list at https://www.npmjs.com/package/@lotics/ui.
|
|
586
647
|
|
|
648
|
+
## Routing
|
|
649
|
+
|
|
650
|
+
\`src/App.tsx\` ships a minimal in-app router. Write plain react-router and wrap
|
|
651
|
+
your routes in \`AppRouter\` from \`@lotics/app-sdk/router\` — it makes screens real,
|
|
652
|
+
addressable URLs in both modes: embedded in the Lotics host the current screen
|
|
653
|
+
lives in the host address bar (\`?_loc=…\`, shareable + refresh-survivable) and the
|
|
654
|
+
browser Back/Forward walk app screens; standalone (\`<slug>.lotics.app\`) it's a
|
|
655
|
+
normal browser router with real path URLs. A single-screen app can drop the
|
|
656
|
+
router and render one screen directly.
|
|
657
|
+
|
|
587
658
|
See https://lotics.ai/docs/app-sdk for the SDK reference.
|
|
588
659
|
`,
|
|
589
660
|
},
|
|
@@ -77,4 +77,24 @@ describe("buildStarterTemplate", () => {
|
|
|
77
77
|
expect(pkg.dependencies["@lotics/ui"]).toBe(`^${STARTER_FALLBACK_UI_VERSION}`);
|
|
78
78
|
expect(pkg.dependencies["@lotics/app-sdk"]).toBe(`^${STARTER_FALLBACK_SDK_VERSION}`);
|
|
79
79
|
});
|
|
80
|
+
test("package.json includes react-router-dom for the in-app router example", () => {
|
|
81
|
+
const pkg = JSON.parse(fileNamed(buildStarterTemplate(baseArgs), "package.json"));
|
|
82
|
+
expect(pkg.dependencies["react-router-dom"]).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
test("App.tsx routes via the SDK's AppRouter (real addressable URLs, both modes)", () => {
|
|
85
|
+
// The scaffold demonstrates the recommended shape: the app uses react-router
|
|
86
|
+
// through `AppRouter`, which makes screens addressable URLs in both modes.
|
|
87
|
+
// Guards against the wiring being dropped back to a single screen.
|
|
88
|
+
const app = fileNamed(buildStarterTemplate(baseArgs), "src/App.tsx");
|
|
89
|
+
expect(app).toContain('from "react-router-dom"');
|
|
90
|
+
expect(app).toContain('from "@lotics/app-sdk/router"');
|
|
91
|
+
expect(app).toContain("<AppRouter routes={routes}");
|
|
92
|
+
});
|
|
93
|
+
test("vite.config.ts pins react-router-dom into the vitest web optimizer (else the routed App test splits the react instance)", () => {
|
|
94
|
+
// react-router-dom ships compiled dist JS; un-pinned, its hooks load a second
|
|
95
|
+
// react instance under vitest and the scaffold's App.test fails with "Invalid
|
|
96
|
+
// hook call" the moment it renders RouterProvider.
|
|
97
|
+
const config = fileNamed(buildStarterTemplate(baseArgs), "vite.config.ts");
|
|
98
|
+
expect(config).toMatch(/include:\s*\[[\s\S]*"react-router-dom"/);
|
|
99
|
+
});
|
|
80
100
|
});
|