@vkma.dev/router 0.0.0-stage → 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/LICENSE +21 -0
- package/dist/core/bridge-like.d.ts +25 -0
- package/dist/core/bridge-like.d.ts.map +1 -0
- package/dist/core/bridge-like.js +13 -0
- package/dist/core/bridge-like.js.map +1 -0
- package/dist/core/bridge-sync.d.ts +37 -0
- package/dist/core/bridge-sync.d.ts.map +1 -0
- package/dist/core/bridge-sync.js +58 -0
- package/dist/core/bridge-sync.js.map +1 -0
- package/dist/core/hash-params.d.ts +40 -0
- package/dist/core/hash-params.d.ts.map +1 -0
- package/dist/core/hash-params.js +60 -0
- package/dist/core/hash-params.js.map +1 -0
- package/dist/core/loose-route.js +9 -0
- package/dist/core/loose-route.js.map +1 -0
- package/dist/core/panels-stack.d.ts +28 -0
- package/dist/core/panels-stack.d.ts.map +1 -0
- package/dist/core/panels-stack.js +60 -0
- package/dist/core/panels-stack.js.map +1 -0
- package/dist/core/state-keys.d.ts +10 -0
- package/dist/core/state-keys.d.ts.map +1 -0
- package/dist/core/state-keys.js +21 -0
- package/dist/core/state-keys.js.map +1 -0
- package/dist/core/swipe-back.d.ts +25 -0
- package/dist/core/swipe-back.d.ts.map +1 -0
- package/dist/core/swipe-back.js +53 -0
- package/dist/core/swipe-back.js.map +1 -0
- package/dist/core/vk-history.d.ts +41 -0
- package/dist/core/vk-history.d.ts.map +1 -0
- package/dist/core/vk-history.js +115 -0
- package/dist/core/vk-history.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/preset/app.d.ts +27 -0
- package/dist/preset/app.d.ts.map +1 -0
- package/dist/preset/app.js +110 -0
- package/dist/preset/app.js.map +1 -0
- package/dist/preset/builders.d.ts +209 -0
- package/dist/preset/builders.d.ts.map +1 -0
- package/dist/preset/builders.js +209 -0
- package/dist/preset/builders.js.map +1 -0
- package/dist/preset/define-panel.d.ts +77 -0
- package/dist/preset/define-panel.d.ts.map +1 -0
- package/dist/preset/define-panel.js +42 -0
- package/dist/preset/define-panel.js.map +1 -0
- package/dist/preset/define-view.d.ts +42 -0
- package/dist/preset/define-view.d.ts.map +1 -0
- package/dist/preset/define-view.js +43 -0
- package/dist/preset/define-view.js.map +1 -0
- package/dist/preset/inflate.d.ts +17 -0
- package/dist/preset/inflate.d.ts.map +1 -0
- package/dist/preset/inflate.js +40 -0
- package/dist/preset/inflate.js.map +1 -0
- package/dist/preset/navigator.d.ts +75 -0
- package/dist/preset/navigator.d.ts.map +1 -0
- package/dist/preset/navigator.js +116 -0
- package/dist/preset/navigator.js.map +1 -0
- package/dist/preset.d.ts +17 -0
- package/dist/preset.js +16 -0
- package/dist/react/hooks.d.ts +69 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +101 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +1 -0
- package/dist/vkui/choreography.js +59 -0
- package/dist/vkui/choreography.js.map +1 -0
- package/dist/vkui/modals.d.ts +49 -0
- package/dist/vkui/modals.d.ts.map +1 -0
- package/dist/vkui/modals.js +116 -0
- package/dist/vkui/modals.js.map +1 -0
- package/dist/vkui/not-found.d.ts +9 -0
- package/dist/vkui/not-found.d.ts.map +1 -0
- package/dist/vkui/not-found.js +23 -0
- package/dist/vkui/not-found.js.map +1 -0
- package/dist/vkui/popouts.d.ts +34 -0
- package/dist/vkui/popouts.d.ts.map +1 -0
- package/dist/vkui/popouts.js +69 -0
- package/dist/vkui/popouts.js.map +1 -0
- package/dist/vkui/vk-view.d.ts +26 -0
- package/dist/vkui/vk-view.d.ts.map +1 -0
- package/dist/vkui/vk-view.js +71 -0
- package/dist/vkui/vk-view.js.map +1 -0
- package/dist/vkui.d.ts +5 -0
- package/dist/vkui.js +5 -0
- package/package.json +70 -3
- package/src/core/bridge-like.ts +25 -0
- package/src/core/bridge-sync.ts +95 -0
- package/src/core/hash-params.ts +73 -0
- package/src/core/loose-route.ts +17 -0
- package/src/core/panels-stack.ts +85 -0
- package/src/core/state-keys.ts +39 -0
- package/src/core/swipe-back.ts +80 -0
- package/src/core/vk-history.ts +146 -0
- package/src/index.ts +30 -0
- package/src/preset/app.tsx +158 -0
- package/src/preset/builders.tsx +566 -0
- package/src/preset/define-panel.ts +109 -0
- package/src/preset/define-view.ts +73 -0
- package/src/preset/inflate.ts +57 -0
- package/src/preset/navigator.ts +252 -0
- package/src/preset.ts +51 -0
- package/src/react/hooks.ts +168 -0
- package/src/react.ts +11 -0
- package/src/vkui/choreography.ts +105 -0
- package/src/vkui/modals.tsx +216 -0
- package/src/vkui/not-found.tsx +21 -0
- package/src/vkui/popouts.tsx +106 -0
- package/src/vkui/vk-view.tsx +136 -0
- package/src/vkui.ts +17 -0
- package/README.md +0 -3
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRootRoute,
|
|
3
|
+
createRoute,
|
|
4
|
+
createRouteMask,
|
|
5
|
+
type AnyRoute,
|
|
6
|
+
type RouteMask,
|
|
7
|
+
} from "@tanstack/react-router";
|
|
8
|
+
import type { ComponentType, LazyExoticComponent, ReactNode } from "react";
|
|
9
|
+
import type { ModalEntry } from "../vkui/modals.tsx";
|
|
10
|
+
import type { PopoutEntry } from "../vkui/popouts.tsx";
|
|
11
|
+
import { AppRootShell } from "./app.tsx";
|
|
12
|
+
import { PANEL_DEFINITION, type PanelDefinition } from "./define-panel.ts";
|
|
13
|
+
import { VIEW_DEFINITION, type ViewDefinition } from "./define-view.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Typed builders that generate the TanStack route tree in the /vkui
|
|
17
|
+
* convention: view = pathless layout route, panels = path children, modals
|
|
18
|
+
* = sibling child routes of their panel (chained modals are siblings — the
|
|
19
|
+
* host closes one before opening the next). Local route modals are
|
|
20
|
+
* URL-masked back to their panel (`app.routeMasks` → `createRouter`'s
|
|
21
|
+
* `routeMasks`): the address stays at the panel while any modal level is
|
|
22
|
+
* open, the raw modal path still deep-links (parsing is never masked), and
|
|
23
|
+
* the mask survives reload (history.state.__tempLocation). Global modals
|
|
24
|
+
* are rendered by the host from `?modal=<id>` on any panel (deep-linkable;
|
|
25
|
+
* the imperative open carries its own mask). Every node accepts raw
|
|
26
|
+
* TanStack options (`loader`, `beforeLoad`, `validateSearch`, `lazy`, …)
|
|
27
|
+
* passed through untouched, and the underlying routes stay reachable for
|
|
28
|
+
* escape hatches.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Route options with the builder-managed fields removed. `AnyRoute`'s
|
|
33
|
+
* options carry TanStack's generics pre-instanced, so this stays a plain
|
|
34
|
+
* structural type; the registered route tree provides the real typing for
|
|
35
|
+
* navigation, loaders and search.
|
|
36
|
+
*/
|
|
37
|
+
export type LooseRouteOptions = Partial<
|
|
38
|
+
Omit<NonNullable<AnyRoute>["options"], "getParentRoute" | "path" | "id">
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
/** A renderable modal entry component — plain or React.lazy. */
|
|
42
|
+
export type ModalEntryComponent = ComponentType | LazyExoticComponent<ComponentType>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A declared modal: a renderable component, or a component with ITS OWN
|
|
46
|
+
* nested modals. Nested modals are child routes of their parent modal and
|
|
47
|
+
* stack ON TOP of it (back unwinds the stack instead of closing everything).
|
|
48
|
+
*/
|
|
49
|
+
export interface ModalDeclaration {
|
|
50
|
+
component: ModalEntryComponent;
|
|
51
|
+
modals?: Record<string, ModalComponent>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a modals record accepts: either shape. */
|
|
55
|
+
export type ModalComponent = ModalEntryComponent | ModalDeclaration;
|
|
56
|
+
|
|
57
|
+
export interface PanelOptions extends LooseRouteOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Chunk splitting without file-based routing: resolves to the panel's
|
|
60
|
+
* component. The preset bridges it into TanStack's `route.lazy()` (which
|
|
61
|
+
* expects a `{ options }`-shaped lazy route) and hands the caller the
|
|
62
|
+
* panel's route id. The payload is either `{ component }` or a dynamic
|
|
63
|
+
* import namespace with a `default` export (`lazy: () => import("./feed")`).
|
|
64
|
+
*/
|
|
65
|
+
lazy?: PanelLazy;
|
|
66
|
+
/** Route-level modals: sibling child routes of this panel (recursible). */
|
|
67
|
+
modals?: Record<string, ModalComponent>;
|
|
68
|
+
/**
|
|
69
|
+
* Panel-local popouts: resolved BEFORE the global registry while this
|
|
70
|
+
* panel is the deepest matched one. The same id declared globally is a
|
|
71
|
+
* dev-time error.
|
|
72
|
+
*/
|
|
73
|
+
popouts?: Record<string, PopoutEntry["Component"]>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The lazy loader shape: an explicit `{ component }` payload or a dynamic
|
|
78
|
+
* import namespace whose `default` export is the component. The arrow keeps
|
|
79
|
+
* the chunk out of the main graph; the union keeps call sites terse
|
|
80
|
+
* (`lazy: () => import("./feed")`).
|
|
81
|
+
*/
|
|
82
|
+
export type PanelLazy = (
|
|
83
|
+
routeId: string,
|
|
84
|
+
) => Promise<{ component: ComponentType } | { default: ComponentType }>;
|
|
85
|
+
|
|
86
|
+
export interface ViewOptions extends LooseRouteOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Panels of the view: raw inline options (small apps, wrap `lazy` in
|
|
89
|
+
* `panel()`) or a {@link PanelDefinition} from `definePanel` — the
|
|
90
|
+
* recommended shape, whose handle lives in a leaf module so panel chunks
|
|
91
|
+
* never import the app back.
|
|
92
|
+
*/
|
|
93
|
+
panels: Record<string, PanelOptions | PanelDefinition>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PanelHandle<
|
|
97
|
+
TParams = Record<string, string>,
|
|
98
|
+
TSearch = Record<string, unknown>,
|
|
99
|
+
TLoaderData = unknown,
|
|
100
|
+
> {
|
|
101
|
+
/** Full TanStack route id — the `from` for the /react data hooks. */
|
|
102
|
+
readonly routeId: string;
|
|
103
|
+
/** URL path of the panel. */
|
|
104
|
+
path: string;
|
|
105
|
+
/** The view route id the panel belongs to. */
|
|
106
|
+
viewId: string;
|
|
107
|
+
/** Phantom carrier — the panel's data types for the /react hooks. */
|
|
108
|
+
readonly types?: { params: TParams; search: TSearch; loaderData: TLoaderData };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `view:feed` + panel `post/$id` → the runtime id `/view:feed/post/$id`. */
|
|
112
|
+
export type PanelRouteId<V extends string, P extends string> = P extends "index"
|
|
113
|
+
? `/view:${V}/`
|
|
114
|
+
: `/view:${V}/${P}`;
|
|
115
|
+
|
|
116
|
+
/** `$id`-segments of a panel path → the params object type. */
|
|
117
|
+
export type ParamsOfKey<P extends string> = P extends `${infer Head}/${infer Rest}`
|
|
118
|
+
? ParamsOfSegment<Head> & ParamsOfKey<Rest>
|
|
119
|
+
: ParamsOfSegment<P>;
|
|
120
|
+
type ParamsOfSegment<S extends string> = S extends `$${infer Name}` ? { [K in Name]: string } : {};
|
|
121
|
+
export type SearchOf<O extends PanelOptions> = O extends {
|
|
122
|
+
validateSearch: (search: never) => infer S;
|
|
123
|
+
}
|
|
124
|
+
? S
|
|
125
|
+
: Record<string, never>;
|
|
126
|
+
export type LoaderDataOf<O extends PanelOptions> = O extends {
|
|
127
|
+
loader: (...args: never[]) => infer L;
|
|
128
|
+
}
|
|
129
|
+
? Awaited<L>
|
|
130
|
+
: unknown;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The options a panels-map member declares — a {@link PanelDefinition}
|
|
134
|
+
* carries them in its third type parameter, raw inline options ARE the
|
|
135
|
+
* options.
|
|
136
|
+
*/
|
|
137
|
+
export type OptionsOf<M extends PanelOptions | PanelDefinition> =
|
|
138
|
+
M extends PanelDefinition<any, any, infer O> ? O : M;
|
|
139
|
+
|
|
140
|
+
type UnionToIntersection<U> = (U extends unknown ? (arg: U) => void : never) extends (
|
|
141
|
+
arg: infer I,
|
|
142
|
+
) => void
|
|
143
|
+
? I
|
|
144
|
+
: never;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The declared panels map with LITERAL route ids and option-derived data
|
|
148
|
+
* types: `app.panels["feed/post/$id"]` autocompletes, is never undefined,
|
|
149
|
+
* its `routeId` is the literal runtime id, and `usePanelParams(handle)` &
|
|
150
|
+
* co. resolve params / search / loaderData from the declared options.
|
|
151
|
+
*/
|
|
152
|
+
export type PanelHandlesOf<TViews extends Record<string, ViewOptions>> = UnionToIntersection<
|
|
153
|
+
{
|
|
154
|
+
[V in keyof TViews & string]: {
|
|
155
|
+
[P in keyof TViews[V]["panels"] & string as `${V}/${P}`]: PanelHandle<
|
|
156
|
+
ParamsOfKey<P>,
|
|
157
|
+
SearchOf<OptionsOf<TViews[V]["panels"][P]>>,
|
|
158
|
+
LoaderDataOf<OptionsOf<TViews[V]["panels"][P]>>
|
|
159
|
+
> & {
|
|
160
|
+
readonly routeId: PanelRouteId<V, P>;
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
}[keyof TViews & string]
|
|
164
|
+
>;
|
|
165
|
+
|
|
166
|
+
export interface AppHandle {
|
|
167
|
+
routeTree: AnyRoute;
|
|
168
|
+
/** Modal entries for the `<Modals>` host. */
|
|
169
|
+
modals: ModalEntry[];
|
|
170
|
+
/**
|
|
171
|
+
* Declarative URL masks for the local route modals — pass into
|
|
172
|
+
* `createRouter({ routeMasks })`, or a modal open puts its own path in
|
|
173
|
+
* the address bar.
|
|
174
|
+
*/
|
|
175
|
+
routeMasks: RouteMask<any>[];
|
|
176
|
+
/** Popout registry for the `<Popouts>` host. */
|
|
177
|
+
popouts: PopoutEntry[];
|
|
178
|
+
/** Panel handles keyed by `${viewKey}/${panelKey}`. */
|
|
179
|
+
panels: Record<string, PanelHandle>;
|
|
180
|
+
views: Record<string, AnyRoute>;
|
|
181
|
+
/** Inflate flag (see {@link AppOptions.inflate}). */
|
|
182
|
+
inflate: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The app handle with the DECLARED panels shape: literal keys
|
|
187
|
+
* (`app.panels["feed/post/$id"]` — autocomplete, never undefined) and
|
|
188
|
+
* literal route ids that drive the /react hooks' typing. Structurally an
|
|
189
|
+
* AppHandle, so it flows into AppRootShell / useInflate / deriveInflateChain.
|
|
190
|
+
*/
|
|
191
|
+
export interface TypedAppHandle<TViews extends Record<string, ViewOptions>> {
|
|
192
|
+
routeTree: AnyRoute;
|
|
193
|
+
modals: ModalEntry[];
|
|
194
|
+
routeMasks: RouteMask<any>[];
|
|
195
|
+
popouts: PopoutEntry[];
|
|
196
|
+
panels: PanelHandlesOf<TViews>;
|
|
197
|
+
views: Record<string, AnyRoute>;
|
|
198
|
+
inflate: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface AppOptionsBase {
|
|
202
|
+
/**
|
|
203
|
+
* Custom root shell (renders inside the router context). Defaults to
|
|
204
|
+
* AppRootShell: the active view + modal/popout hosts. Deliberately
|
|
205
|
+
* LOOSE (`AppHandle`, not `TypedAppHandle<TViews>`): a contextual type
|
|
206
|
+
* mentioning TViews makes the app const's own initializer circular
|
|
207
|
+
* (TS7022) — typed panel handles are read from the RETURN value.
|
|
208
|
+
*/
|
|
209
|
+
root?: (props: { app: AppHandle }) => ReactNode;
|
|
210
|
+
/** Global modals: `?modal=<id>` opens them from any panel. */
|
|
211
|
+
modals?: Record<string, ModalEntryComponent>;
|
|
212
|
+
/** Global popout registry. */
|
|
213
|
+
popouts?: Record<string, PopoutEntry["Component"]>;
|
|
214
|
+
/**
|
|
215
|
+
* Inflate (default `true`): a cold start on a deep-linked panel replays
|
|
216
|
+
* the panel flow before it, so back walks the natural chain.
|
|
217
|
+
*/
|
|
218
|
+
inflate?: boolean;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface AppOptions<
|
|
222
|
+
TViews extends Record<string, ViewOptions> = Record<string, ViewOptions>,
|
|
223
|
+
> extends AppOptionsBase {
|
|
224
|
+
views: TViews;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `createApp` with `defineView` hubs — handles live on the panel definitions. */
|
|
228
|
+
export interface AppDefinitionOptions extends AppOptionsBase {
|
|
229
|
+
views: readonly ViewDefinition[] | Record<string, ViewDefinition>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Type-erasing wrapper for `lazy` panel chunks. The chunk module imports
|
|
234
|
+
* the app back for its typed panel handles — passing its component types
|
|
235
|
+
* straight into `views` makes the app's own inference circular (the
|
|
236
|
+
* component's inferred type depends on `app.panels`). `panel()` erases the
|
|
237
|
+
* chunk to `{ component: ComponentType }` at the boundary.
|
|
238
|
+
*
|
|
239
|
+
* ```tsx
|
|
240
|
+
* lazy: panel(() => import("./panels/feed").then(m => ({ component: m.FeedPanel })))
|
|
241
|
+
* lazy: panel(routeId => import("./panels/post.lazy").then(m => m.postLazy(routeId)))
|
|
242
|
+
* ```
|
|
243
|
+
*/
|
|
244
|
+
/**
|
|
245
|
+
* Identity eraser for RAW inline `lazy` loaders (small apps declaring panels
|
|
246
|
+
* directly in `createApp`): the chunk modules import the app back for their
|
|
247
|
+
* handles, so the loader's return type must not carry the chunk's component
|
|
248
|
+
* type into the app's own inference. Apps using `definePanel` don't need it
|
|
249
|
+
* — the definition boundary already erases.
|
|
250
|
+
*/
|
|
251
|
+
export function panel(load: PanelLazy): PanelLazy {
|
|
252
|
+
return load;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Normalized view entries the builder loop consumes. */
|
|
256
|
+
interface DeclaredView {
|
|
257
|
+
viewKey: string;
|
|
258
|
+
viewRest: LooseRouteOptions;
|
|
259
|
+
viewPanels: Record<string, PanelOptions | PanelDefinition>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeViews(views: unknown): DeclaredView[] {
|
|
263
|
+
if (Array.isArray(views)) {
|
|
264
|
+
return (views as readonly ViewDefinition[]).map(definition => ({
|
|
265
|
+
viewKey: definition.view,
|
|
266
|
+
viewRest: definition.options ?? {},
|
|
267
|
+
viewPanels: { ...definition.panels },
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
return Object.entries(views as Record<string, unknown>).map(([viewKey, declared]) => {
|
|
271
|
+
if (VIEW_DEFINITION in (declared as object)) {
|
|
272
|
+
const definition = declared as ViewDefinition;
|
|
273
|
+
if (definition.view !== viewKey) {
|
|
274
|
+
console.error(
|
|
275
|
+
`[vkma-router] view "${definition.view}" is nested under key "${viewKey}"; the hub's view string and the nesting key must match`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
viewKey: definition.view,
|
|
280
|
+
viewRest: definition.options ?? {},
|
|
281
|
+
viewPanels: { ...definition.panels },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const { panels, ...viewRest } = declared as ViewOptions;
|
|
285
|
+
return { viewKey, viewRest: viewRest as LooseRouteOptions, viewPanels: panels ?? {} };
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Normalize a declared modal into its declaration form. */
|
|
290
|
+
function asDeclaration(value: ModalComponent): ModalDeclaration {
|
|
291
|
+
// shape check, NOT typeof: lazy/memo/forwardRef components are objects
|
|
292
|
+
return typeof value === "object" && value !== null && "component" in value
|
|
293
|
+
? value
|
|
294
|
+
: { component: value };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Build a modal subtree under `parentRoute` (a panel or a parent modal) and
|
|
299
|
+
* register every entry (flat) into the host registry. Nested modals are
|
|
300
|
+
* child routes of their parent modal — the stacking is expressed by the
|
|
301
|
+
* route tree itself.
|
|
302
|
+
*/
|
|
303
|
+
function createModalTree(
|
|
304
|
+
parentRoute: AnyRoute,
|
|
305
|
+
modals: Record<string, ModalComponent>,
|
|
306
|
+
registry: ModalEntry[],
|
|
307
|
+
baseKey: string,
|
|
308
|
+
/** Full path of the OWNING panel — every level's mask displays it. */
|
|
309
|
+
panelFullPath: string,
|
|
310
|
+
/** Full path of the parent in the modal chain (panel or parent modal). */
|
|
311
|
+
parentFullPath: string,
|
|
312
|
+
/** Mask pairs collected for the whole app, mapped to RouteMask later. */
|
|
313
|
+
masks: Array<{ from: string; to: string }>,
|
|
314
|
+
): AnyRoute[] {
|
|
315
|
+
const routes: AnyRoute[] = [];
|
|
316
|
+
for (const [modalKey, declared] of Object.entries(modals)) {
|
|
317
|
+
const { component, modals: nested } = asDeclaration(declared);
|
|
318
|
+
const modalRoute = createRoute({
|
|
319
|
+
getParentRoute: () => parentRoute,
|
|
320
|
+
path: modalKey,
|
|
321
|
+
staticData: { modal: `${baseKey}/${modalKey}` },
|
|
322
|
+
});
|
|
323
|
+
const modalFullPath =
|
|
324
|
+
parentFullPath === "/" ? `/${modalKey}` : `${parentFullPath}/${modalKey}`;
|
|
325
|
+
// while ANY level of the chain is open the URL keeps showing the
|
|
326
|
+
// OWNING panel's path — nested levels mask to the panel too, so the
|
|
327
|
+
// display never leaks a modal segment (the raw modal path still
|
|
328
|
+
// deep-links: parsing is never masked)
|
|
329
|
+
masks.push({ from: modalFullPath, to: panelFullPath });
|
|
330
|
+
registry.push({
|
|
331
|
+
get routeId() {
|
|
332
|
+
return modalRoute.id;
|
|
333
|
+
},
|
|
334
|
+
get id() {
|
|
335
|
+
return modalRoute.id;
|
|
336
|
+
},
|
|
337
|
+
Component: component,
|
|
338
|
+
});
|
|
339
|
+
routes.push(modalRoute);
|
|
340
|
+
if (nested !== undefined) {
|
|
341
|
+
const children = createModalTree(
|
|
342
|
+
modalRoute,
|
|
343
|
+
nested,
|
|
344
|
+
registry,
|
|
345
|
+
`${baseKey}/${modalKey}`,
|
|
346
|
+
panelFullPath,
|
|
347
|
+
modalFullPath,
|
|
348
|
+
masks,
|
|
349
|
+
);
|
|
350
|
+
if (children.length > 0) {
|
|
351
|
+
// addChildren REPLACES the children array — one call per route
|
|
352
|
+
modalRoute.addChildren(children);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return routes;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Definition-form views: the typed handles live on the panel definitions. */
|
|
360
|
+
export function createApp(options: AppDefinitionOptions): AppHandle;
|
|
361
|
+
/** Raw inline options: `app.panels` is fully typed from the declared tree. */
|
|
362
|
+
export function createApp<const TViews extends Record<string, ViewOptions>>(
|
|
363
|
+
options: AppOptions<TViews>,
|
|
364
|
+
): TypedAppHandle<TViews>;
|
|
365
|
+
export function createApp(options: AppOptions | AppDefinitionOptions): any {
|
|
366
|
+
const modals: ModalEntry[] = [];
|
|
367
|
+
const pendingMasks: Array<{ from: string; to: string }> = [];
|
|
368
|
+
const popouts: PopoutEntry[] = Object.entries(options.popouts ?? {}).map(([id, Component]) => ({
|
|
369
|
+
id,
|
|
370
|
+
Component,
|
|
371
|
+
}));
|
|
372
|
+
const globalPopoutIds = new Set(Object.keys(options.popouts ?? {}));
|
|
373
|
+
|
|
374
|
+
const views: Record<string, AnyRoute> = {};
|
|
375
|
+
const panels: Record<string, PanelHandle> = {};
|
|
376
|
+
/**
|
|
377
|
+
* TanStack finalizes route ids in `route.init()` during `createRouter` —
|
|
378
|
+
* they are not readable at build time. The handle therefore exposes
|
|
379
|
+
* GETTERS: consumers read `routeId` at render time, when the router
|
|
380
|
+
* (and the ids) already exist.
|
|
381
|
+
*/
|
|
382
|
+
const deferredPanels: Array<{
|
|
383
|
+
key: string;
|
|
384
|
+
path: string;
|
|
385
|
+
route: AnyRoute;
|
|
386
|
+
}> = [];
|
|
387
|
+
/** full panel path → owning panel key, for the duplicate-path dev check */
|
|
388
|
+
const usedPaths = new Map<string, string>();
|
|
389
|
+
const usedViewKeys = new Set<string>();
|
|
390
|
+
|
|
391
|
+
const customRoot = options.root;
|
|
392
|
+
const rootRoute = createRootRoute({
|
|
393
|
+
// a custom root receives the app handle (plus the raw match props);
|
|
394
|
+
// the default is the auto-shell. Both close over the handle — the
|
|
395
|
+
// component only runs at render time, after the binding exists.
|
|
396
|
+
component: (customRoot
|
|
397
|
+
? (routeProps: Record<string, unknown>) =>
|
|
398
|
+
customRoot({ ...routeProps, app: handle }) as never
|
|
399
|
+
: () => <AppRootShell app={handle} />) as never,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
for (const { viewKey, viewRest, viewPanels } of normalizeViews(options.views)) {
|
|
403
|
+
if (usedViewKeys.has(viewKey)) {
|
|
404
|
+
console.error(
|
|
405
|
+
`[vkma-router] duplicate view "${viewKey}"; a view must be declared exactly once (one defineView hub)`,
|
|
406
|
+
);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
usedViewKeys.add(viewKey);
|
|
410
|
+
const viewRoute = createRoute({
|
|
411
|
+
getParentRoute: () => rootRoute,
|
|
412
|
+
id: `view:${viewKey}`,
|
|
413
|
+
...(viewRest as Partial<Record<string, unknown>>),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const panelRoutes: AnyRoute[] = [];
|
|
417
|
+
for (const [panelKey, declared] of Object.entries(viewPanels)) {
|
|
418
|
+
// a definition carries its options inside; raw options ARE the options
|
|
419
|
+
const panelOptions: PanelOptions =
|
|
420
|
+
PANEL_DEFINITION in declared ? declared.options : declared;
|
|
421
|
+
if (PANEL_DEFINITION in declared && declared.view !== viewKey) {
|
|
422
|
+
console.error(
|
|
423
|
+
`[vkma-router] panel "${panelKey}" is defined for view "${declared.view}" but nested under "views.${viewKey}"; its handle route id and the tree would disagree`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
const { modals: panelModals, lazy, popouts: panelPopouts, ...panelRest } = panelOptions;
|
|
427
|
+
// panel-local popouts: resolved before the global registry while
|
|
428
|
+
// this panel is the deepest matched one; a global id collision is
|
|
429
|
+
// a dev-time error (the local declaration would shadow it)
|
|
430
|
+
const panelRegistryKey = `${viewKey}/${panelKey}`;
|
|
431
|
+
const localPopoutIds = new Set<string>();
|
|
432
|
+
for (const [popoutId, Component] of Object.entries(panelPopouts ?? {})) {
|
|
433
|
+
if (globalPopoutIds.has(popoutId)) {
|
|
434
|
+
console.error(
|
|
435
|
+
`[vkma-router] popout "${popoutId}" is declared both globally and on panel "${panelRegistryKey}"; the local one shadows the global — rename one of them`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (localPopoutIds.has(popoutId)) {
|
|
439
|
+
console.error(
|
|
440
|
+
`[vkma-router] duplicate popout "${popoutId}" on panel "${panelRegistryKey}"`,
|
|
441
|
+
);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
localPopoutIds.add(popoutId);
|
|
445
|
+
popouts.push({ id: popoutId, Component, panel: panelRegistryKey });
|
|
446
|
+
}
|
|
447
|
+
const panelPath = panelKey === "index" ? "/" : panelKey;
|
|
448
|
+
// dev check: two panels with the same full path silently break
|
|
449
|
+
// matching (the router resolves the conflict arbitrarily) — a
|
|
450
|
+
// multi-view app must give every home panel its own segment
|
|
451
|
+
const pathOwner = usedPaths.get(panelPath);
|
|
452
|
+
if (pathOwner !== undefined) {
|
|
453
|
+
console.error(
|
|
454
|
+
`[vkma-router] duplicate panel path "${panelPath}" (${pathOwner} and ${viewKey}/${panelKey}); only one view may own "/" — give the other home panels distinct path segments (e.g. "feed")`,
|
|
455
|
+
);
|
|
456
|
+
} else {
|
|
457
|
+
usedPaths.set(panelPath, `${viewKey}/${panelKey}`);
|
|
458
|
+
}
|
|
459
|
+
const panelRoute = createRoute({
|
|
460
|
+
getParentRoute: () => viewRoute,
|
|
461
|
+
path: panelPath,
|
|
462
|
+
staticData: { panel: `${viewKey}/${panelKey}` },
|
|
463
|
+
...(panelRest as Partial<Record<string, unknown>>),
|
|
464
|
+
});
|
|
465
|
+
if (lazy !== undefined) {
|
|
466
|
+
// runs on match load — the router (and ids) exist by then;
|
|
467
|
+
// TanStack's route.lazy() merges `lazyRoute.options`, the
|
|
468
|
+
// builder bridges the plain-options contract (both payload
|
|
469
|
+
// shapes) into that shape
|
|
470
|
+
panelRoute.lazy(
|
|
471
|
+
() =>
|
|
472
|
+
Promise.resolve(lazy(panelRoute.id)).then(payload => ({
|
|
473
|
+
options:
|
|
474
|
+
"component" in payload ? payload : { component: payload.default },
|
|
475
|
+
})) as never,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
// the modal subtree (nested modals are child routes of their
|
|
479
|
+
// parent modal); entries register flat into the host registry.
|
|
480
|
+
// The view is a pathless layout route — the panel's full path is
|
|
481
|
+
// just its declared path rooted at "/"
|
|
482
|
+
const panelFullPath = panelPath === "/" ? "/" : `/${panelPath.replace(/^\//, "")}`;
|
|
483
|
+
const modalRoutes = createModalTree(
|
|
484
|
+
panelRoute,
|
|
485
|
+
panelModals ?? {},
|
|
486
|
+
modals,
|
|
487
|
+
`${viewKey}/${panelKey}`,
|
|
488
|
+
panelFullPath,
|
|
489
|
+
panelFullPath,
|
|
490
|
+
pendingMasks,
|
|
491
|
+
);
|
|
492
|
+
// addChildren REPLACES the children array — one call after the loop
|
|
493
|
+
// (per-modal calls would orphan every modal but the last)
|
|
494
|
+
panelRoute.addChildren(modalRoutes);
|
|
495
|
+
panelRoutes.push(panelRoute);
|
|
496
|
+
deferredPanels.push({
|
|
497
|
+
key: `${viewKey}/${panelKey}`,
|
|
498
|
+
path: panelPath,
|
|
499
|
+
route: panelRoute,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
viewRoute.addChildren(panelRoutes);
|
|
504
|
+
views[viewKey] = viewRoute;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
rootRoute.addChildren(Object.values(views));
|
|
508
|
+
|
|
509
|
+
// Declarative masks for the local route modals: while a modal is open
|
|
510
|
+
// the router displays the owning panel's path (createRouteMask is
|
|
511
|
+
// identity — the router matches `from` at navigation time and stores
|
|
512
|
+
// the real location in history.state.__tempLocation, so the raw modal
|
|
513
|
+
// path still deep-links and the mask survives reload)
|
|
514
|
+
const routeMasks = pendingMasks.map(
|
|
515
|
+
mask =>
|
|
516
|
+
createRouteMask({
|
|
517
|
+
routeTree: rootRoute as never,
|
|
518
|
+
from: mask.from,
|
|
519
|
+
to: mask.to,
|
|
520
|
+
// modal routes are children of their panel — the params ($id, …)
|
|
521
|
+
// are shared, forward them to the displayed location wholesale
|
|
522
|
+
params: true,
|
|
523
|
+
} as never) as RouteMask<any>,
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
for (const { key, path, route } of deferredPanels) {
|
|
527
|
+
panels[key] = {
|
|
528
|
+
get routeId() {
|
|
529
|
+
return route.id;
|
|
530
|
+
},
|
|
531
|
+
path,
|
|
532
|
+
get viewId() {
|
|
533
|
+
// runtime ids carry a leading slash: "/view:feed" — split("/")[0]
|
|
534
|
+
// is ALWAYS "" (that silently broke the inflate view filter: every
|
|
535
|
+
// panel matched every view, so a cold home deep link replayed the
|
|
536
|
+
// first other view's panels before the target)
|
|
537
|
+
const match = String(route.id).match(/^\/?view:([^/]+)/);
|
|
538
|
+
return match?.[1] ?? String(route.id);
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// global modals: no route — the Modals host opens them from the
|
|
544
|
+
// `?modal=<id>` search param on any panel (deep-linkable; the
|
|
545
|
+
// imperative open in useNavigator masks the URL back to the panel)
|
|
546
|
+
for (const [modalKey, Modal] of Object.entries(options.modals ?? {})) {
|
|
547
|
+
modals.push({
|
|
548
|
+
routeId: `?modal=${modalKey}`,
|
|
549
|
+
id: modalKey,
|
|
550
|
+
Component: Modal,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const handle: AppHandle = {
|
|
555
|
+
routeTree: rootRoute,
|
|
556
|
+
modals,
|
|
557
|
+
routeMasks,
|
|
558
|
+
popouts,
|
|
559
|
+
panels,
|
|
560
|
+
views,
|
|
561
|
+
inflate: options.inflate ?? true,
|
|
562
|
+
};
|
|
563
|
+
// the TypedAppHandle overload's literal route-id types are only
|
|
564
|
+
// computable through the caller's generic
|
|
565
|
+
return handle;
|
|
566
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LoaderDataOf,
|
|
3
|
+
PanelOptions,
|
|
4
|
+
PanelLazy,
|
|
5
|
+
PanelRouteId,
|
|
6
|
+
ParamsOfKey,
|
|
7
|
+
SearchOf,
|
|
8
|
+
} from "./builders.ts";
|
|
9
|
+
|
|
10
|
+
/** Brand distinguishing a {@link PanelDefinition} from raw inline options. */
|
|
11
|
+
export const PANEL_DEFINITION = "__vkmaPanel" as const;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A self-contained panel declaration: the definition IS the typed handle.
|
|
15
|
+
*
|
|
16
|
+
* Panel chunk modules import the DEFINITION (never the app) for their data
|
|
17
|
+
* hooks — the definition's stored type is CLOSED (its `options` are widened
|
|
18
|
+
* and the concrete option-derived types travel in the `types` phantom), so
|
|
19
|
+
* the type graph stays acyclic and the app import disappears from chunks
|
|
20
|
+
* entirely. `createApp` nests definitions under their view key and builds
|
|
21
|
+
* the routes from `options`.
|
|
22
|
+
*/
|
|
23
|
+
export interface PanelDefinition<
|
|
24
|
+
V extends string = string,
|
|
25
|
+
P extends string = string,
|
|
26
|
+
O extends PanelOptions = PanelOptions,
|
|
27
|
+
> {
|
|
28
|
+
readonly [PANEL_DEFINITION]: true;
|
|
29
|
+
/** The view (story) namespace — must match the key under `views`. */
|
|
30
|
+
readonly view: V;
|
|
31
|
+
/** The panel path segment (`"index"` = the view's home panel). */
|
|
32
|
+
readonly path: P;
|
|
33
|
+
/**
|
|
34
|
+
* The declared options, WIDENED on purpose: a widened stored type cannot
|
|
35
|
+
* reference chunk modules, so importing the definition from a chunk can
|
|
36
|
+
* never close a type cycle. The concrete option-derived data types
|
|
37
|
+
* travel in the phantom below.
|
|
38
|
+
*/
|
|
39
|
+
readonly options: PanelOptions;
|
|
40
|
+
/** Runtime route id by convention (`/view:${view}/${path}`). */
|
|
41
|
+
readonly routeId: PanelRouteId<V, P>;
|
|
42
|
+
/**
|
|
43
|
+
* Phantom carrying the option-derived data types for the /react hooks.
|
|
44
|
+
* `O` is the options WITHOUT `lazy` (the loader is a separate factory
|
|
45
|
+
* parameter) — its arrow would otherwise embed the chunk module's
|
|
46
|
+
* (deferred) member types into the definition, the exact cycle this
|
|
47
|
+
* module exists to break.
|
|
48
|
+
*/
|
|
49
|
+
readonly types?: {
|
|
50
|
+
params: ParamsOfKey<P>;
|
|
51
|
+
search: SearchOf<O>;
|
|
52
|
+
loaderData: LoaderDataOf<O>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Declare a panel:
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* export const feedPanel = definePanel("feed", "feed", {
|
|
61
|
+
* loader: async () => ({ posts }),
|
|
62
|
+
* }, () => import("./feed"));
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* The returned definition nests directly into `createApp`'s
|
|
66
|
+
* `views[view].panels[path]` and doubles as the typed handle for the
|
|
67
|
+
* `/react` hooks — chunk modules import it instead of the app handle, which
|
|
68
|
+
* is what keeps the app's inference and the chunk graph acyclic. The view
|
|
69
|
+
* key MUST match the nesting key in `createApp` (a mismatch is a dev-time
|
|
70
|
+
* console error, not a silent hook break).
|
|
71
|
+
*/
|
|
72
|
+
export function definePanel<
|
|
73
|
+
const V extends string,
|
|
74
|
+
const P extends string,
|
|
75
|
+
O extends PanelOptions = PanelOptions,
|
|
76
|
+
>(
|
|
77
|
+
view: V,
|
|
78
|
+
path: P,
|
|
79
|
+
options: O,
|
|
80
|
+
/**
|
|
81
|
+
* The lazy loader as a SEPARATE parameter — not a member of `options`.
|
|
82
|
+
* Its arrow references the UI chunk's module, whose member types include
|
|
83
|
+
* the (deferred) component signatures; if that arrow were part of the
|
|
84
|
+
* inferred `O`, a chunk component resolving hooks against this very
|
|
85
|
+
* definition would close the inference cycle (TS7022). A separate
|
|
86
|
+
* parameter is contextually typed by `PanelLazy` directly — the module
|
|
87
|
+
* type never enters `O`. Mirrors TanStack's own options/`route.lazy()`
|
|
88
|
+
* split.
|
|
89
|
+
*/
|
|
90
|
+
lazy?: PanelLazy,
|
|
91
|
+
): PanelDefinition<V, P, O> {
|
|
92
|
+
return {
|
|
93
|
+
[PANEL_DEFINITION]: true,
|
|
94
|
+
view,
|
|
95
|
+
path,
|
|
96
|
+
options: lazy === undefined ? options : { ...options, lazy },
|
|
97
|
+
// same formula as panelRouteId — TS cannot see the literal through the
|
|
98
|
+
// template, the cast records the guarantee
|
|
99
|
+
routeId: panelRouteId(view, path) as PanelRouteId<V, P>,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The deterministic route-id convention shared with `createApp`'s tree
|
|
105
|
+
* assembly — one formula, two consumers, no drift.
|
|
106
|
+
*/
|
|
107
|
+
export function panelRouteId(view: string, path: string): string {
|
|
108
|
+
return path === "index" ? `/view:${view}/` : `/view:${view}/${path}`;
|
|
109
|
+
}
|